diff options
Diffstat (limited to 'interfaces/email/interactive')
-rw-r--r-- | interfaces/email/interactive/_procmailrc | 22 | ||||
-rwxr-xr-x | interfaces/email/interactive/be-handle-mail | 283 | ||||
l--------- | interfaces/email/interactive/becommands | 1 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/blank | 0 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/comment | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/help | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/invalid_command | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/invalid_subject | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/list | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/missing_command | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/new | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/show | 9 | ||||
-rw-r--r-- | interfaces/email/interactive/examples/unicode | 9 | ||||
l--------- | interfaces/email/interactive/libbe | 1 | ||||
-rw-r--r-- | interfaces/email/interactive/send_pgp_mime.py | 608 |
15 files changed, 996 insertions, 0 deletions
diff --git a/interfaces/email/interactive/_procmailrc b/interfaces/email/interactive/_procmailrc new file mode 100644 index 0000000..56f11e5 --- /dev/null +++ b/interfaces/email/interactive/_procmailrc @@ -0,0 +1,22 @@ +# .procmailrc +# +# see man procmail, procmailrc, and procmailex +# +# If you already have a ~/.procmailrc file, you probably only need to +# insert the bug-email grabbing stanza in your ~/.procmailrc. +# +# This file is released to the Public Domain. + +MAILDIR=$HOME/be-mail +LOGFILE=$MAILDIR/procmail.log + +# Grab all incoming bug emails (but not replies). This rule eats +# matching emails (i.e. no further procmail processing). +:0 +* ^Subject: [be-mail] +* !^Subject:.*[be-bug].*Re: +| be-handle-mail + +# Drop everything else +:0 +/dev/null diff --git a/interfaces/email/interactive/be-handle-mail b/interfaces/email/interactive/be-handle-mail new file mode 100755 index 0000000..c3769be --- /dev/null +++ b/interfaces/email/interactive/be-handle-mail @@ -0,0 +1,283 @@ +#!/usr/bin/env python +# +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Provide and email interface to the distributed bugtracker Bugs +Everywhere. Recieves incoming email via procmail and allows users to +select actions with their subject lines. Subject lines follow the +format + [be-bug] command (options) (args) +With the body of the email being used as the final argument for the +commands "new" and "comment", and ignored otherwise. The options and +arguments are split on whitespace, so don't use whitespace inside a +single argument. + +Eventually we'll commit after every message. +""" + +import codecs +import cStringIO as StringIO +import email +import email.utils +import libbe.cmdutil, libbe.encoding, libbe.utility +import os +import os.path +import send_pgp_mime +import sys +import time +import traceback + +SUBJECT_COMMENT = "[be-bug]" +HANDLER_ADDRESS = "BE Bugs <wking@thor.physics.drexel.edu>" +_THIS_DIR = os.path.abspath(os.path.dirname(__file__)) +BE_DIR = _THIS_DIR +LOGPATH = os.path.join(_THIS_DIR, "be-handle-mail.log") +LOGFILE = None + +libbe.encoding.ENCODING = "utf-8" # force default encoding +ENCODING = libbe.encoding.get_encoding() + +ALLOWED_COMMANDS = ["new", "comment", "list", "show", "help"] + +class InvalidEmail (ValueError): + def __init__(self, msg, info, message): + ValueError.__init__(self, message) + self.msg = msg + self.info = info + def response(self): + ret = 1 + out_text = None + return (ret, out_text, self.stderr_msg(), self.info) + def stderr_msg(self): + err_text = [u"Invalid email (particular type unknown):\n", + unicode(self), u"", + send_pgp_mime.flatten(self.msg, to_unicode=True)] + return u"\n".join(err_text) + +class InvalidSubject (InvalidEmail): + def stderr_msg(self): + err_text = u"\n".join([u"InvalidSubject:\n", + unicode(self), u"", + u"full subject was:", + self.msg["subject"]]) + return err_text + +class InvalidCommand (InvalidEmail): + def __init__(self, msg, info, command): + message = "Invalid command '%s'" % command + InvalidEmail.__init__(self, msg, info, message) + self.command = command + def stderr_msg(self): + err_text = u"\n".join([u"InvalidCommand:\n", + unicode(self), u"", + u"full subject was:", + self.msg["subject"]]) + return err_text + +def get_body_type(msg): + for part in msg.walk(): + if part.is_multipart(): + continue + return (part.get_payload(decode=1), part.get_content_type()) + +def run_message(msg_text): + """ + Attempt to execute the email given in the email string msg_text. + Raises assorted subclasses of InvalidEmail in the case of invalid + messages, otherwise return the exit code, stdout, and stderr + produced by the command, as well as a dictionary of information + gleaned from the email. + """ + p=email.Parser.Parser() + msg=p.parsestr(msg_text) + + info = {} + author = send_pgp_mime.source_email(msg, return_realname=True) + info["author_name"] = author[0] + info["author_email"] = author[1] + info["author_addr"] = email.utils.formataddr( + (info["author_name"], info["author_email"])) + info["message-id"] = msg["message-id"] + if LOGFILE != None: + LOGFILE.write("handling %s\n" % (info["author_addr"])) + LOGFILE.write("\n%s\n\n" % msg_text) + if "subject" not in msg: + raise InvalidSubject(msg, info, "Email must contain a subject") + args = msg["subject"].split() + if len(args) < 1 or args[0] != SUBJECT_COMMENT: + raise InvalidSubject( + msg, info, "Subject must start with '%s '" % SUBJECT_COMMENT) + elif len(args) < 2: + raise InvalidCommand(msg, info, "") # don't accept blank commands + command = args[1] + info["command"] = command + if command not in ALLOWED_COMMANDS: + raise InvalidCommand(msg, info, command) + if len(args) > 2: + command_args = args[2:] + else: + command_args = [] + stdin = None + if command in ["new", "comment"]: + body,mime_type = get_body_type(msg) + if command == "new": + if "--reporter" not in args and "-r" not in args: + command_args = ["--reporter", info["author_addr"]]+command_args + body = body.strip().split("\n", 1)[0] # only take first line + elif command == "comment": + if "--author" not in args and "-a" not in args: + command_args = ["--author", info["author_addr"]] + command_args + if "--content-type" not in args and "-c" not in args: + command_args = ["--content-type", mime_type] + command_args + if "--alt-id" not in args: + command_args = ["--alt-id", msg["message-id"]] + command_args + command_args.append("-") + stdin = body + info["command-args"] = command_args + # set stdin and catch stdout and stderr + new_stdin = StringIO.StringIO(stdin) + new_stdout = codecs.getwriter(ENCODING)(StringIO.StringIO()) + new_stderr = codecs.getwriter(ENCODING)(StringIO.StringIO()) + orig_stdin = sys.stdin + orig_stdout = sys.stdout + orig_stderr = sys.stderr + sys.stdin = new_stdin + sys.stdout = new_stdout + sys.stderr = new_stderr + # run the command + err = None + os.chdir(BE_DIR) + try: + ret = libbe.cmdutil.execute(command, command_args, + manipulate_encodings=False) + except libbe.cmdutil.GetHelp: + print libbe.cmdutil.help(command) + except libbe.cmdutil.GetCompletions: + err = InvalidCommand(msg, info, "invalid option '--complete'") + except libbe.cmdutil.UsageError, e: + err = InvalidCommand(msg, info, e) + except libbe.cmdutil.UserError, e: + err = InvalidCommand(msg, info, e) + # restore stdin, stdout, and stderr + sys.stdout.flush() + sys.stderr.flush() + sys.stdin = orig_stdin + sys.stdout = orig_stdout + sys.stderr = orig_stderr + out_text = codecs.decode(new_stdout.getvalue(), ENCODING) + err_text = codecs.decode(new_stderr.getvalue(), ENCODING) + if err != None: + raise err + if LOGFILE != None: + LOGFILE.write(u"stdout? " + str(type(out_text))) + LOGFILE.write(u"\n%s\n\n" % out_text) + return (ret, out_text, err_text, info) + +def compose_response(ret, out_text, err_text, info): + if "author_addr" not in info: + return None + if "command" not in info: + info["command"] = u"-BLANK-" + if "command_args" not in info: + info["command_args"] = [] + response_header = [u"From: %s" % HANDLER_ADDRESS, + u"To: %s" % info["author_addr"], + u"Date: %s" % libbe.utility.time_to_str(time.time()), + u"Subject: %s Re: %s"%(SUBJECT_COMMENT,info["command"]), + ] + if "message-id" in info: + response_header.append(u"In-reply-to: %s" % info["message-id"]) + response_body = [u"Results of running: (exit code %d)" % ret, + u" %s %s" % (info["command"], + u" ".join(info["command_args"]))] + if out_text != None and len(out_text) > 0: + response_body.extend([u"", u"stdout:", u"", out_text]) + if err_text != None and len(err_text) > 0: + response_body.extend([u"", u"stderr:", u"", err_text]) + response_body.append(u"") # trailing endline + response_email = send_pgp_mime.Mail(u"\n".join(response_header), + u"\n".join(response_body)) + if LOGFILE != None: + LOGFILE.write("responding to %s: %s\n" + % (info["author_addr"], info["command"])) + LOGFILE.write("\n%s\n\n" + % send_pgp_mime.flatten(response_email.plain(), + to_unicode=True)) + return response_email + +def open_logfile(logpath=None): + """ + If logpath=None, default to global LOGPATH. + Special logpath strings: + "-" set LOGFILE to sys.stderr + "none" disable logging + Relative logpaths are expanded relative to _THIS_DIR + """ + global LOGPATH, LOGFILE + if logpath != None: + if logpath == "-": + LOGPATH = "stderr" + LOGFILE = sys.stderr + elif logpath == "none": + LOGPATH = "none" + LOGFILE = None + elif os.path.isabs(logpath): + LOGPATH = logpath + else: + LOGPATH = os.path.join(_THIS_DIR, logpath) + if LOGFILE == None and LOGPATH != "none": + LOGFILE = codecs.open(LOGPATH, "a+", ENCODING) + LOGFILE.write("Default encoding: %s\n" % ENCODING) + +def close_logfile(): + if LOGFILE != None and LOGPATH not in ["stderr", "none"]: + LOGFILE.close() + + +def main(): + from optparse import OptionParser + + usage="be-handle-mail [options]\n\n%s" % (__doc__) + parser = OptionParser(usage=usage) + parser.add_option('-o', '--output', dest='output', action='store_true', + help="Don't mail the generated message, print it to stdout instead. Useful for testing be-handle-mail functionality without the whole mail transfer agent and procmail setup.") + parser.add_option('-l', '--logfile', dest='logfile', metavar='LOGFILE', + help='Set the logfile to LOGFILE. Relative paths are relative to the location of this be-handle-mail file (%s). The special value of "-" directs the log output to stderr, and "none" disables logging.' % _THIS_DIR) + + options,args = parser.parse_args() + + msg_text = sys.stdin.read() + libbe.encoding.set_IO_stream_encodings(ENCODING) # _after_ reading message + open_logfile(options.logfile) + try: + ret,out_text,err_text,info = run_message(msg_text) + except InvalidEmail, e: + ret,out_text,err_text,info = e.response() + except Exception, e: + if LOGFILE != None: + LOGFILE.write("Uncaught exception:\n%s\n" % (e,)) + traceback.print_tb(sys.exc_traceback, file=LOGFILE) + close_logfile() + sys.exit(1) + response_email = compose_response(ret, out_text, err_text, info).plain() + if options.output == True: + print send_pgp_mime.flatten(response_email, to_unicode=True) + else: + send_pgp_mime.mail(response_email, send_pgp_mime.sendmail) + close_logfile() + +if __name__ == "__main__": + main() diff --git a/interfaces/email/interactive/becommands b/interfaces/email/interactive/becommands new file mode 120000 index 0000000..8af773c --- /dev/null +++ b/interfaces/email/interactive/becommands @@ -0,0 +1 @@ +../../../becommands
\ No newline at end of file diff --git a/interfaces/email/interactive/examples/blank b/interfaces/email/interactive/examples/blank new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/interfaces/email/interactive/examples/blank diff --git a/interfaces/email/interactive/examples/comment b/interfaces/email/interactive/examples/comment new file mode 100644 index 0000000..1d60748 --- /dev/null +++ b/interfaces/email/interactive/examples/comment @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <xyz@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] comment a1d + +We sure do. diff --git a/interfaces/email/interactive/examples/help b/interfaces/email/interactive/examples/help new file mode 100644 index 0000000..14e887c --- /dev/null +++ b/interfaces/email/interactive/examples/help @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] help + +Dummy content diff --git a/interfaces/email/interactive/examples/invalid_command b/interfaces/email/interactive/examples/invalid_command new file mode 100644 index 0000000..4d18f09 --- /dev/null +++ b/interfaces/email/interactive/examples/invalid_command @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] close + +Dummy content diff --git a/interfaces/email/interactive/examples/invalid_subject b/interfaces/email/interactive/examples/invalid_subject new file mode 100644 index 0000000..e148d0b --- /dev/null +++ b/interfaces/email/interactive/examples/invalid_subject @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: Spam! + +Dummy content diff --git a/interfaces/email/interactive/examples/list b/interfaces/email/interactive/examples/list new file mode 100644 index 0000000..333315f --- /dev/null +++ b/interfaces/email/interactive/examples/list @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] list --status all + +Dummy content diff --git a/interfaces/email/interactive/examples/missing_command b/interfaces/email/interactive/examples/missing_command new file mode 100644 index 0000000..fefe41b --- /dev/null +++ b/interfaces/email/interactive/examples/missing_command @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] abcde + +Dummy content diff --git a/interfaces/email/interactive/examples/new b/interfaces/email/interactive/examples/new new file mode 100644 index 0000000..7ac6dce --- /dev/null +++ b/interfaces/email/interactive/examples/new @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] new + +Need tests for the email interface. diff --git a/interfaces/email/interactive/examples/show b/interfaces/email/interactive/examples/show new file mode 100644 index 0000000..3ff56f4 --- /dev/null +++ b/interfaces/email/interactive/examples/show @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] show --xml 361 + +Dummy content diff --git a/interfaces/email/interactive/examples/unicode b/interfaces/email/interactive/examples/unicode new file mode 100644 index 0000000..e5b0775 --- /dev/null +++ b/interfaces/email/interactive/examples/unicode @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] show --xml f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a + +Dummy content diff --git a/interfaces/email/interactive/libbe b/interfaces/email/interactive/libbe new file mode 120000 index 0000000..7d18612 --- /dev/null +++ b/interfaces/email/interactive/libbe @@ -0,0 +1 @@ +../../../libbe
\ No newline at end of file diff --git a/interfaces/email/interactive/send_pgp_mime.py b/interfaces/email/interactive/send_pgp_mime.py new file mode 100644 index 0000000..e0451c9 --- /dev/null +++ b/interfaces/email/interactive/send_pgp_mime.py @@ -0,0 +1,608 @@ +#!/usr/bin/python +# +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Python module and command line tool for sending pgp/mime email. + +Mostly uses subprocess to call gpg and a sendmail-compatible mailer. +If you lack gpg, either don't use the encryption functions or adjust +the pgp_* commands. You may need to adjust the sendmail command to +point to whichever sendmail-compatible mailer you have on your system. +""" + +from cStringIO import StringIO +import os +import re +#import GnuPGInterface # Maybe should use this instead of subprocess +import smtplib +import subprocess +import sys +import tempfile +import types + +try: + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + from email.mime.application import MIMEApplication + from email.encoders import encode_7or8bit + from email.generator import Generator + from email.parser import Parser + from email.utils import getaddress +except ImportError: + # adjust to old python 2.4 + from email.MIMEText import MIMEText + from email.MIMEMultipart import MIMEMultipart + from email.MIMENonMultipart import MIMENonMultipart + from email.Encoders import encode_7or8bit + 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): + MIMENonMultipart.__init__(self, 'application', _subtype, **params) + self.set_payload(_data) + _encoder(self) + +usage="""usage: %prog [options] + +Scriptable PGP MIME email using gpg. + +You can use gpg-agent for passphrase caching if your key requires a +passphrase (it better!). Example usage would be to install gpg-agent, +and then run + export GPG_TTY=`tty` + eval $(gpg-agent --daemon) +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] +Both of these alternatives are much less secure than gpg-agent. You +have been warned. +""" + +verboseInvoke = False +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. +# %p Expands to the passphrase argument. +# %R A string with some number (0 on up) of pgp_reciepient_arg +# 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 +# optionally print a string based upon one of the above sequences, the +# following construct is used +# %?<sequence_char>?<optional_string>? +# where sequence_char is a character from the table above, and +# optional_string is the string you would like printed if status_char +# is nonzero. optional_string may contain other sequence as well as +# normal text, but it may not contain any question marks. +# +# see http://codesorcery.net/old/mutt/mutt-gnupg-howto +# http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign +# http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html +# for more details + +pgp_recipient_arg='-r "%r"' +pgp_stdin_passphrase_arg='--passphrase-fd 0' +pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f' +pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f' +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 execute(args, stdin=None, expect=(0,)): + """ + Execute a command (allows us to drive gpg). + """ + if verboseInvoke == True: + print >> sys.stderr, '$ '+args + try: + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True) + except OSError, e: + strerror = '%s\nwhile executing %s' % (e.args[1], args) + raise Exception, strerror + output, error = p.communicate(input=stdin) + status = p.wait() + if verboseInvoke == True: + print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error) + if status not in expect: + strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status) + raise Exception, strerror + return status, output, error + +def replace(template, format_char, replacement_text): + """ + >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in') + '--textmode %?a?-u %a? file.in' + >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY') + '--textmode -u 0xHEXKEY %f' + >>> replace('--textmode %?a?-u %a? %f', 'a', '') + '--textmode %f' + """ + if replacement_text == None: + replacement_text = "" + regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]') + if len(replacement_text) > 0: + str = regexp.sub('\g<1>', template) + else: + str = regexp.sub('', template) + regexp = re.compile('%'+format_char) + str = regexp.sub(replacement_text, str) + return str + +def flatten(msg, to_unicode=False): + """ + Produce flat text output from an email Message instance. + """ + assert msg != None + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(msg) + text = fp.getvalue() + if to_unicode == True: + encoding = msg.get_content_charset() + text = unicode(text, encoding=encoding) + return text + +def source_email(msg, return_realname=False): + """ + Search the header of an email Message instance to find the + sender's email address. + """ + froms = msg.get_all('from', []) + from_tuples = getaddresses(froms) # [(realname, email_address), ...] + assert len(from_tuples) == 1 + if return_realname == True: + return from_tuples[0] # (realname, email_address) + return from_tuples[0][1] # email_address + +def target_emails(msg): + """ + Search the header of an email Message instance to find a + list of recipient's email addresses. + """ + tos = msg.get_all('to', []) + ccs = msg.get_all('cc', []) + bccs = msg.get_all('bcc', []) + 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) + 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): + """ + 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'] + >>> print flatten(m.clearBodyPart()) + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + <BLANKLINE> + check 1 2 + check 1 2 + <BLANKLINE> + >>> print flatten(m.plain()) + 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.set_boundary('boundsep') + >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4) + 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 + check 1 2 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Description: signature + Content-Type: application/pgp-signature; name="signature.asc"; + charset="us-ascii" + <BLANKLINE> + -----BEGIN PGP SIGNATURE----- + SIGNATURE STRIPPED (depends on current time) + -----END PGP SIGNATURE----- + <BLANKLINE> + --boundsep-- + >>> encrypted = m.encrypt() + >>> encrypted.set_boundary('boundsep') + >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4) + 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 + Content-Type: application/pgp-encrypted + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + Version: 1 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + 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.set_boundary('boundsep') + >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4) + 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 + Content-Type: application/pgp-encrypted + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + Version: 1 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + 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() + 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.add_header('Content-Disposition', 'inline') + return body + def passphrase_arg(self, passphrase=None): + if passphrase == None and PASSPHRASE != None: + passphrase = PASSPHRASE + if passphrase == None: + return (None,'') + return (passphrase, pgp_stdin_passphrase_arg) + 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): + """ + multipart/signed + +-> text/plain (body) + +-> application/pgp-signature (signature) + """ + passphrase,pass_arg = self.passphrase_arg(passphrase) + body = self.clearBodyPart() + bfile = tempfile.NamedTemporaryFile() + bfile.write(flatten(body)) + bfile.flush() + + args = replace(pgp_sign_command, 'f', bfile.name) + if PGP_SIGN_AS == None: + pgp_sign_as = '<%s>' % self.sourceEmail() + 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['Content-Description'] = 'signature' + sig.set_charset('us-ascii') + + 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): + """ + multipart/encrypted + +-> application/pgp-encrypted (control information) + +-> application/octet-stream (body) + """ + body = self.clearBodyPart() + bfile = tempfile.NamedTemporaryFile() + bfile.write(flatten(body)) + bfile.flush() + + recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()]) + 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() + 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.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') + 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): + """ + multipart/encrypted + +-> application/pgp-encrypted (control information) + +-> application/octet-stream (body) + """ + passphrase,pass_arg = self.passphrase_arg(passphrase) + body = self.sign() + 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()]) + 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() + 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.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') + 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 + doctest.testmod() + + +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') + + 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: + if options.passphrase_fd == 0: + stdin_used = True + 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 + stdin_used = True + header = sys.stdin.read() + else: + header = file(options.header_filename, 'r').read() + if header == None: + raise Exception, "missing header" + body = None + if options.body_filename != None: + if options.body_filename == '-': + assert stdin_used == False + stdin_used = True + body = sys.stdin.read() + else: + body = file(options.body_filename, 'r').read() + if body == None: + raise Exception, "missing body" + + m = Mail(header, body) + if options.mode == "sign": + message = m.sign() + elif options.mode == "encrypt": + message = m.encrypt() + elif options.mode == "sign-encrypt": + message = m.signAndEncrypt() + elif options.mode == "plain": + message = m.plain() + else: + print "Unrecognized mode '%s'" % options.mode + + if options.output == True: + message = flatten(message) + print message + else: + mail(message, sendmail) |