1 # -*- coding: utf-8 -*-
2 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
4 # This file is part of pgp-mime.
6 # pgp-mime is free software: you can redistribute it and/or modify it under the
7 # terms of the GNU General Public License as published by the Free Software
8 # Foundation, either version 3 of the License, or (at your option) any later
11 # pgp-mime is distributed in the hope that it will be useful, but WITHOUT ANY
12 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
13 # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License along with
16 # pgp-mime. If not, see <http://www.gnu.org/licenses/>.
17 """Python module and for constructing and sending pgp/mime email.
19 Mostly uses subprocess to call ``gpg`` and sends mail using either
20 SMTP or a sendmail-compatible mailer. If you lack ``gpg``, either
21 don't use the encryption functions, adjust the ``GPG_*`` constants, or
22 adjust the ``*_bytes`` commands.
25 import configparser as _configparser
27 import logging as _logging
30 import smtplib as _smtplib
31 import smtplib as _smtplib
32 import subprocess as _subprocess
34 from email.encoders import encode_7or8bit as _encode_7or8bit
35 from email.generator import Generator as _Generator
36 from email.header import decode_header as _decode_header
37 from email.message import Message as _Message
38 from email.mime.application import MIMEApplication as _MIMEApplication
39 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
40 from email.mime.text import MIMEText as _MIMEText
41 from email.parser import Parser as _Parser
42 from email.utils import formataddr as _formataddr
43 from email.utils import getaddresses as _getaddresses
49 LOG = _logging.getLogger('pgp-mime')
50 LOG.setLevel(_logging.ERROR)
51 LOG.addHandler(_logging.StreamHandler())
54 #ENCODING = 'iso-8859-1'
57 '/usr/bin/gpg', '--no-verbose', '--quiet', '--batch', '--output', '-',
58 '--armor', '--textmode']
59 GPG_SIGN_ARGS = ['--detach-sign']
60 GPG_ENCRYPT_ARGS = ['--encrypt', '--always-trust']
61 GPG_SIGN_AND_ENCRYPT_ARGS = ['--sign', '--encrypt', '--always-trust']
62 SENDMAIL = ['/usr/sbin/sendmail', '-t']
65 def get_smtp_params(config):
66 r"""Retrieve SMTP paramters from a config file.
68 >>> from configparser import ConfigParser
69 >>> config = ConfigParser()
70 >>> config.read_string('\n'.join([
72 ... 'host: smtp.mail.uu.edu',
75 ... 'username: rincewind',
76 ... 'password: 7ugg@g3',
78 >>> get_smtp_params(config)
79 ('smtp.mail.uu.edu', 587, True, 'rincewind', '7ugg@g3')
80 >>> config = ConfigParser()
81 >>> get_smtp_params(ConfigParser())
82 (None, None, None, None, None)
85 host = config.get('smtp', 'host')
86 except _configparser.NoSectionError:
87 return (None, None, None, None, None)
88 except _configparser.NoOptionError:
91 port = config.getint('smtp', 'port')
92 except _configparser.NoOptionError:
95 starttls = config.getboolean('smtp', 'starttls')
96 except _configparser.NoOptionError:
99 username = config.get('smtp', 'username')
100 except _configparser.NoOptionError:
103 password = config.get('smtp', 'password')
104 except _configparser.NoOptionError:
106 return (host, port, starttls, username, password)
108 def get_smtp(host=None, port=None, starttls=None, username=None,
110 """Connect to an SMTP host using the given parameters.
113 >>> try: # doctest: +SKIP
114 ... smtp = get_smtp(host='smtp.gmail.com', port=587, starttls=True,
115 ... username='rincewind@uu.edu', password='7ugg@g3')
116 ... except smtplib.SMTPAuthenticationError as error:
117 ... print('that was not a real account')
118 that was not a real account
119 >>> smtp = get_smtp() # doctest: +SKIP
120 >>> smtp.quit() # doctest: +SKIP
125 port = _smtplib.SMTP_PORT
126 if username and not starttls:
128 'sending passwords in the clear is unsafe! Use STARTTLS.')
129 LOG.info('connect to SMTP server at {}:{}'.format(host, port))
130 smtp = _smtplib.SMTP(host=host, port=port)
135 smtp.login(username, password)
136 #smtp.set_debuglevel(1)
139 def mail(message, smtp=None, sendmail=None):
140 """Send an email ``Message`` instance on its merry way.
142 We can shell out to the user specified sendmail in case
143 the local host doesn't have an SMTP server set up
144 for easy ``smtplib`` usage.
146 >>> message = encodedMIMEText('howdy!')
147 >>> message['From'] = 'John Doe <jdoe@a.gov.ru>'
148 >>> message['To'] = 'Jack <jack@hill.org>, Jill <jill@hill.org>'
149 >>> mail(message=message, sendmail=SENDMAIL)
151 LOG.info('send message {} -> {}'.format(message['from'], message['to']))
153 smtp.send_message(msg=message)
155 execute(sendmail, stdin=message.as_string().encode('us-ascii'))
157 smtp = _smtplib.SMTP()
159 smtp.send_message(msg=message)
162 def header_from_text(text):
163 r"""Simple wrapper for instantiating a ``Message`` from text.
165 >>> text = '\n'.join(
166 ... ['From: me@big.edu','To: you@big.edu','Subject: testing'])
167 >>> header = header_from_text(text=text)
168 >>> print(header.as_string()) # doctest: +REPORT_UDIFF
177 return p.parsestr(text, headersonly=True)
179 def guess_encoding(text):
181 >>> guess_encoding('hi there')
183 >>> guess_encoding('✉')
186 for encoding in ['us-ascii', ENCODING, 'utf-8']:
188 text.encode(encoding)
189 except UnicodeEncodeError:
193 raise ValueError(text)
195 def encodedMIMEText(body, encoding=None):
196 """Wrap ``MIMEText`` with ``guess_encoding`` detection.
198 >>> message = encodedMIMEText('Hello')
199 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
200 Content-Type: text/plain; charset="us-ascii"
202 Content-Transfer-Encoding: 7bit
203 Content-Disposition: inline
206 >>> message = encodedMIMEText('Джон Доу')
207 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
208 Content-Type: text/plain; charset="utf-8"
210 Content-Transfer-Encoding: base64
211 Content-Disposition: inline
217 encoding = guess_encoding(body)
218 if encoding == 'us-ascii':
219 message = _MIMEText(body)
221 # Create the message ('plain' stands for Content-Type: text/plain)
222 message = _MIMEText(body, 'plain', encoding)
223 message.add_header('Content-Disposition', 'inline')
226 def strip_bcc(message):
227 """Remove the Bcc field from a ``Message`` in preparation for mailing
229 >>> message = encodedMIMEText('howdy!')
230 >>> message['To'] = 'John Doe <jdoe@a.gov.ru>'
231 >>> message['Bcc'] = 'Jack <jack@hill.org>, Jill <jill@hill.org>'
232 >>> message = strip_bcc(message)
233 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
234 Content-Type: text/plain; charset="us-ascii"
236 Content-Transfer-Encoding: 7bit
237 Content-Disposition: inline
238 To: John Doe <jdoe@a.gov.ru>
243 del message['resent-bcc']
246 def append_text(text_part, new_text):
247 r"""Append text to the body of a ``plain/text`` part.
249 Updates encoding as necessary.
251 >>> message = encodedMIMEText('Hello')
252 >>> append_text(message, ' John Doe')
253 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
254 Content-Type: text/plain; charset="us-ascii"
256 Content-Disposition: inline
257 Content-Transfer-Encoding: 7bit
260 >>> append_text(message, ', Джон Доу')
261 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
263 Content-Disposition: inline
264 Content-Type: text/plain; charset="utf-8"
265 Content-Transfer-Encoding: base64
267 SGVsbG8gSm9obiBEb2UsINCU0LbQvtC9INCU0L7Rgw==
269 >>> append_text(message, ', and Jane Sixpack.')
270 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
272 Content-Disposition: inline
273 Content-Type: text/plain; charset="utf-8"
274 Content-Transfer-Encoding: base64
276 SGVsbG8gSm9obiBEb2UsINCU0LbQvtC9INCU0L7RgywgYW5kIEphbmUgU2l4cGFjay4=
279 original_encoding = text_part.get_charset().input_charset
280 original_payload = str(
281 text_part.get_payload(decode=True), original_encoding)
282 new_payload = '{}{}'.format(original_payload, new_text)
283 new_encoding = guess_encoding(new_payload)
284 if text_part.get('content-transfer-encoding', None):
285 # clear CTE so set_payload will set it properly for the new encoding
286 del text_part['content-transfer-encoding']
287 text_part.set_payload(new_payload, new_encoding)
289 def attach_root(header, root_part):
290 r"""Copy headers from ``header`` onto ``root_part``.
292 >>> header = header_from_text('From: me@big.edu\n')
293 >>> body = encodedMIMEText('Hello')
294 >>> message = attach_root(header, body)
295 >>> print(message.as_string()) # doctest: +REPORT_UDIFF
296 Content-Type: text/plain; charset="us-ascii"
298 Content-Transfer-Encoding: 7bit
299 Content-Disposition: inline
304 for k,v in header.items():
308 def execute(args, stdin=None, expect=(0,), env=_os.environ):
309 """Execute a command (allows us to drive gpg).
311 LOG.debug('$ {}'.format(args))
313 p = _subprocess.Popen(
314 args, stdin=_subprocess.PIPE, stdout=_subprocess.PIPE,
315 stderr=_subprocess.PIPE, shell=False, close_fds=True, env=env)
317 raise Exception('{}\nwhile executing {}'.format(e.args[1], args))
318 output,error = p.communicate(input=stdin)
320 LOG.debug('(status: {})\n{}{}'.format(status, output, error))
321 if status not in expect:
322 raise Exception('unexpected status while executing {}\n{}\n{}'.format(
323 args, error, status))
324 return (status, output, error)
326 def getaddresses(addresses):
327 """A decoding version of ``email.utils.getaddresses``.
329 >>> text = ('To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
330 ... 'Jack <jack@hill.org>')
331 >>> header = header_from_text(text=text)
332 >>> list(getaddresses(header.get_all('to', [])))
333 [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
335 for (name,address) in _getaddresses(addresses):
337 for b,encoding in _decode_header(name):
341 n.append(str(b, encoding))
342 yield (' '.join(n), address)
344 def email_sources(message):
345 """Extract author address from an email ``Message``
347 Search the header of an email Message instance to find the
348 senders' email addresses (or sender's address).
350 >>> text = ('From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
351 ... 'Jack <jack@hill.org>')
352 >>> header = header_from_text(text=text)
353 >>> list(email_sources(header))
354 [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
356 froms = message.get_all('from', [])
357 return getaddresses(froms) # [(name, address), ...]
359 def email_targets(message):
360 """Extract recipient addresses from an email ``Message``
362 Search the header of an email Message instance to find a
363 list of recipient's email addresses.
365 >>> text = ('To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
366 ... 'Jack <jack@hill.org>')
367 >>> header = header_from_text(text=text)
368 >>> list(email_targets(header))
369 [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
371 tos = message.get_all('to', [])
372 ccs = message.get_all('cc', [])
373 bccs = message.get_all('bcc', [])
374 resent_tos = message.get_all('resent-to', [])
375 resent_ccs = message.get_all('resent-cc', [])
376 resent_bccs = message.get_all('resent-bcc', [])
378 tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
380 def sign_bytes(bytes, sign_as=None):
381 r"""Sign ``bytes`` as ``sign_as``.
383 >>> print(sign_bytes(bytes(b'Hello'), 'wking@drexel.edu'))
384 ... # doctest: +ELLIPSIS
385 b'-----BEGIN PGP SIGNATURE-----\n...-----END PGP SIGNATURE-----\n'
387 args = GPG_ARGS + GPG_SIGN_ARGS
389 args.extend(['--local-user', sign_as])
390 status,output,error = execute(args, stdin=bytes)
393 def encrypt_bytes(bytes, recipients):
394 r"""Encrypt ``bytes`` to ``recipients``.
396 >>> encrypt_bytes(bytes(b'Hello'), ['wking@drexel.edu'])
397 ... # doctest: +ELLIPSIS
398 b'-----BEGIN PGP MESSAGE-----\n...-----END PGP MESSAGE-----\n'
400 args = GPG_ARGS + GPG_ENCRYPT_ARGS
402 raise ValueError('no recipients specified for encryption')
403 for recipient in recipients:
404 args.extend(['--recipient', recipient])
405 status,output,error = execute(args, stdin=bytes)
408 def sign_and_encrypt_bytes(bytes, sign_as=None, recipients=None):
409 r"""Sign ``bytes`` as ``sign_as`` and encrypt to ``recipients``.
411 >>> sign_and_encrypt_bytes(
412 ... bytes(b'Hello'), 'wking@drexel.edu', ['wking@drexel.edu'])
413 ... # doctest: +ELLIPSIS
414 b'-----BEGIN PGP MESSAGE-----\n...-----END PGP MESSAGE-----\n'
416 args = GPG_ARGS + GPG_SIGN_AND_ENCRYPT_ARGS
418 args.extend(['--local-user', sign_as])
420 raise ValueError('no recipients specified for encryption')
421 for recipient in recipients:
422 args.extend(['--recipient', recipient])
423 status,output,error = execute(args, stdin=bytes)
426 def sign(message, sign_as=None):
427 r"""Sign a ``Message``, returning the signed version.
430 +-> text/plain (body)
431 +-> application/pgp-signature (signature)
433 >>> message = encodedMIMEText('Hi\nBye')
434 >>> signed = sign(message, sign_as='0xFC29BDCDF15F5BE8')
435 >>> signed.set_boundary('boundsep')
436 >>> print(signed.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
437 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
439 Content-Disposition: inline
442 Content-Type: text/plain; charset="us-ascii"
444 Content-Transfer-Encoding: 7bit
445 Content-Disposition: inline
451 Content-Transfer-Encoding: 7bit
452 Content-Description: OpenPGP digital signature
453 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
455 -----BEGIN PGP SIGNATURE-----
457 -----END PGP SIGNATURE-----
461 >>> from email.mime.multipart import MIMEMultipart
462 >>> message = MIMEMultipart()
463 >>> message.attach(encodedMIMEText('Part A'))
464 >>> message.attach(encodedMIMEText('Part B'))
465 >>> signed = sign(message, sign_as='0xFC29BDCDF15F5BE8')
466 >>> signed.set_boundary('boundsep')
467 >>> print(signed.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
468 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
470 Content-Disposition: inline
473 Content-Type: multipart/mixed; boundary="===============...=="
476 --===============...==
477 Content-Type: text/plain; charset="us-ascii"
479 Content-Transfer-Encoding: 7bit
480 Content-Disposition: inline
483 --===============...==
484 Content-Type: text/plain; charset="us-ascii"
486 Content-Transfer-Encoding: 7bit
487 Content-Disposition: inline
490 --===============...==--
493 Content-Transfer-Encoding: 7bit
494 Content-Description: OpenPGP digital signature
495 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
497 -----BEGIN PGP SIGNATURE-----
499 -----END PGP SIGNATURE-----
503 body = message.as_string().encode('us-ascii')
504 signature = str(sign_bytes(body, sign_as), 'us-ascii')
505 sig = _MIMEApplication(
507 _subtype='pgp-signature; name="signature.asc"',
508 _encoder=_encode_7or8bit)
509 sig['Content-Description'] = 'OpenPGP digital signature'
510 sig.set_charset('us-ascii')
512 msg = _MIMEMultipart(
513 'signed', micalg='pgp-sha1', protocol='application/pgp-signature')
516 msg['Content-Disposition'] = 'inline'
519 def encrypt(message, recipients=None):
520 r"""Encrypt a ``Message``, returning the encrypted version.
523 +-> application/pgp-encrypted (control information)
524 +-> application/octet-stream (body)
526 >>> message = encodedMIMEText('Hi\nBye')
527 >>> message['To'] = '"W. Trevor King" <wking@drexel.edu>'
528 >>> encrypted = encrypt(message)
529 >>> encrypted.set_boundary('boundsep')
530 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
531 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
533 Content-Disposition: inline
537 Content-Transfer-Encoding: 7bit
538 Content-Type: application/pgp-encrypted; charset="us-ascii"
544 Content-Transfer-Encoding: 7bit
545 Content-Description: OpenPGP encrypted message
546 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
548 -----BEGIN PGP MESSAGE-----
550 -----END PGP MESSAGE-----
554 >>> from email.mime.multipart import MIMEMultipart
555 >>> message = MIMEMultipart()
556 >>> message.attach(encodedMIMEText('Part A'))
557 >>> message.attach(encodedMIMEText('Part B'))
558 >>> encrypted = encrypt(message, recipients=['F15F5BE8'])
559 >>> encrypted.set_boundary('boundsep')
560 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
561 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
563 Content-Disposition: inline
567 Content-Transfer-Encoding: 7bit
568 Content-Type: application/pgp-encrypted; charset="us-ascii"
574 Content-Transfer-Encoding: 7bit
575 Content-Description: OpenPGP encrypted message
576 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
578 -----BEGIN PGP MESSAGE-----
580 -----END PGP MESSAGE-----
584 body = message.as_string().encode('us-ascii')
585 if recipients is None:
586 recipients = [email for name,email in email_targets(message)]
587 LOG.debug('extracted encryption recipients: {}'.format(recipients))
588 encrypted = str(encrypt_bytes(body, recipients), 'us-ascii')
589 enc = _MIMEApplication(
591 _subtype='octet-stream; name="encrypted.asc"',
592 _encoder=_encode_7or8bit)
593 enc['Content-Description'] = 'OpenPGP encrypted message'
594 enc.set_charset('us-ascii')
595 control = _MIMEApplication(
596 _data='Version: 1\n',
597 _subtype='pgp-encrypted',
598 _encoder=_encode_7or8bit)
599 control.set_charset('us-ascii')
600 msg = _MIMEMultipart(
603 protocol='application/pgp-encrypted')
606 msg['Content-Disposition'] = 'inline'
609 def sign_and_encrypt(message, sign_as=None, recipients=None):
610 r"""Sign and encrypt a ``Message``, returning the encrypted version.
613 +-> application/pgp-encrypted (control information)
614 +-> application/octet-stream (body)
616 >>> message = encodedMIMEText('Hi\nBye')
617 >>> message['To'] = '"W. Trevor King" <wking@drexel.edu>'
618 >>> encrypted = sign_and_encrypt(message)
619 >>> encrypted.set_boundary('boundsep')
620 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
621 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
623 Content-Disposition: inline
627 Content-Transfer-Encoding: 7bit
628 Content-Type: application/pgp-encrypted; charset="us-ascii"
634 Content-Transfer-Encoding: 7bit
635 Content-Description: OpenPGP encrypted message
636 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
638 -----BEGIN PGP MESSAGE-----
640 -----END PGP MESSAGE-----
644 >>> from email.mime.multipart import MIMEMultipart
645 >>> message = MIMEMultipart()
646 >>> message.attach(encodedMIMEText('Part A'))
647 >>> message.attach(encodedMIMEText('Part B'))
648 >>> encrypted = sign_and_encrypt(message, recipients=['F15F5BE8'])
649 >>> encrypted.set_boundary('boundsep')
650 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
651 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
653 Content-Disposition: inline
657 Content-Transfer-Encoding: 7bit
658 Content-Type: application/pgp-encrypted; charset="us-ascii"
664 Content-Transfer-Encoding: 7bit
665 Content-Description: OpenPGP encrypted message
666 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
668 -----BEGIN PGP MESSAGE-----
670 -----END PGP MESSAGE-----
674 strip_bcc(message=message)
675 body = message.as_string().encode('us-ascii')
676 if recipients is None:
677 recipients = [email for name,email in email_targets(message)]
678 LOG.debug('extracted encryption recipients: {}'.format(recipients))
679 encrypted = str(sign_and_encrypt_bytes(
680 body, sign_as=sign_as, recipients=recipients), 'us-ascii')
681 enc = _MIMEApplication(
683 _subtype='octet-stream; name="encrypted.asc"',
684 _encoder=_encode_7or8bit)
685 enc['Content-Description'] = 'OpenPGP encrypted message'
686 enc.set_charset('us-ascii')
687 control = _MIMEApplication(
688 _data='Version: 1\n',
689 _subtype='pgp-encrypted',
690 _encoder=_encode_7or8bit)
691 control.set_charset('us-ascii')
692 msg = _MIMEMultipart(
695 protocol='application/pgp-encrypted')
698 msg['Content-Disposition'] = 'inline'