"""
This is a sort of trivial replacement for schleuder.

Essentially, you pipe mail into it; it will decrypt it if necessary
and then forward it encrypted for a set of recipients -- to those 
recipients.

We read both inline and PGP/MIME, we believe we write RFC3156 compliant
PGP/MIME.

To use it, you'll probably want to create a new system user:

	sudo adduser --disabled-login spgex

Install this script into that user's bin directory:

	sudo -u spgex mkdir /home/spgex/bin
	sudo -u spgex tee /home/spgex/bin/simplepgpexploder.py < simplepgpexploder.py

(or put the stuff there less flamboyantly).

Also make sure that mails to root are forwarded somewhere where they're read.
simplepgpgexploder sends distress signals (e.g., botched list config) there.
So, if it's not there already, add a line like

	root: me@my-domain.dom

to /etc/aliases (this can, of course, also be a local account).

Now let's say you want a list og@victor.local.  First, make a private key 
in that user's keyring for og@victor.local (you can share it between all 
lists or have one per list).  Since you're not supposed to log in as spgex
and gpg2 wants ownership of the controlling terminal, go through tmux like 
this::

	sudo -Hu spgex tmux
	gpg --gen-key

The full name should be something like "The xyz list" or something
similarly descriptive. There must be no passphrase on it (yes, this is
only concerned with things on wires, not things on disks).

The remaining commands assume you're still in the tmux shell.

Export the lists's public key for later dissemination:

	gpg --export --armor og@victor.local > list-pubkey.asc

Next, import keys of all people on your mailing lists:

	gpg --import list-keyring.asc

(or whatever; do whatever you normally do to acquire keys).

Finally, write a mailing list config.  That's json that must look like this:

	{
		"admin_addr": "me@example.com",
		"members": ["foo@bar.com", "quux@foo.org",
			"anything@etc.de"],
		"list_addr": "thislist@list-server.net"
	}

If you want to use the recipe below for exim, make a subdiriectory lists in
spgex's home and have the file name be the local part of your list address:

	mkdir /home/spgex/lists
	vi /home/spgex/lists/og

Make it a habit to verify this list configuration everytime you edit it;
to keep the command line simple, just become spgex and run things from its 
home:

	python bin/simplepgpexploder.py -c lists/og

The error messages for malformed JSON currently aren't terribly helpful;
if you have inclination to fix python's build-in JSON parser in that
respect, that would be most welcome.  Meanwhile, you can run jsonlint
on the list files if you don't spot the error right away.

Finally, arrange for incoming mail for the list address to be piped to
simplepgpexploder.py <path to the conf file>.  

With Debian exim, put this into /etc/exim4/conf.d/router/550_spgex:

  spgex:
     debug_print = "R: simple pgp exploder for $local_part@$domain"
     driver = accept
     domains = +local_domains
     require_files = spgex:/home/spgex/lists/${local_part}
     transport = spgex_transport

and this into /etc/exim4/conf.d/transport/30_spgex:

  spgex_transport:
      debug_print = "T: spgex_transport for ${recipient}"
      driver = pipe
      user = spgex
      group = spgex
      home_directory = "/home/spgex"
      command = "python /home/spgex/bin/simplepgpexploder.py lists/${local_part}"
      temp_errors = 1

At least if you're using Debian's split-config approach, don't forget to
run

	sudo update-exim4.conf

after that (and whenever you change something there).


To move from a schleuder list, become root and go to the schleuder list
directory in /var/lib/schleuder/<host name>/<list name>.

Then import the pubkeys present into spgex's keyring:

	gpg --export --homedir . | sudo -u spgex gpg --import

Get the passphrase for the secret key from
/etc/schleuder/lists/<host name>/<list name>.conf.  Then remove the 
passphrase from the secret key:

	gpg --homedir . --edit-key <list key id>
	(use the passwd command, and unless you have a good alternative plan, yes,
	you'll have to have a passphrase-less private key)

Then export it into spgex's keyring:

	gpg --homedir . --export-secret-keys | sudo -u spgex gpg --import

Finally, export the addresses from members.conf:

	grep "email:" members.conf | sed -e 's/.*: //;s/.*/"&",/' |\
		sudo -u spgex tee /home/spgex/lists/newmembers

Exit the root shell, become spgex, create a list configuration and
use newmembers as contents of members.
"""

import argparse
import itertools
import json
import os
import subprocess
import sys
import traceback
import warnings

from cStringIO import StringIO
from email.encoders import encode_7or8bit, encode_base64
from email.mime.application import MIMEApplication
from email.mime.message import MIMEMessage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.parser import Parser

import gpgme


class NoKey(Exception):
	pass


def get_key_for(email, ctx):
	"""returns a a key for email.

	This will return a non-expired key that has a non-revoked uid for email.
	If no such thing exists, some suitable exception is being raised.
	"""
	for key in ctx.keylist(email):
		if key.expired:
			continue
		for uid in key.uids:
			if uid.revoked:
				continue
			if uid.email==email:
				return key

	raise NoKey("Cannot find a non-expired, non-revoked key for %s"%
		email)


class MailingList(object):
	"""a mailing list.

	This is instanciated with a string containing json describing the
	list.  This json has to contain of string-valued keys admin_addr and
	list_addr, and a list-valued key members.
	"""
	options = (gpgme.ENCRYPT_ALWAYS_TRUST)

	def __init__(self, serialised):
		conf = json.loads(serialised)
		self.recipients = conf["members"]
		self.admin_addr = conf["admin_addr"]
		self.list_addr = conf["list_addr"]

	@classmethod
	def from_file(cls, fname):
		with open(fname) as f:
			return cls(f.read())

	@classmethod
	def verify_from_file(cls, fname):
		"""tries to construct a cls from fname, verifying that the list
		is ok.

		This will produce some (more or less helpful) diagnostics if not
		and return 1; it will return 0 otherwise.
		"""
		try:
			res = cls.from_file(fname)
		except Exception, ex:
			sys.stderr.write("Invalid configuration file: %s\n"%repr(ex))
			return 1

		# let's see if we have a key for everyone on the list
		rtval = 0
		ctx = gpgme.Context()
		for recip in res.recipients:
			try:
				key = get_key_for(recip, ctx)
			except Exception as msg:
				sys.stderr.write("Key retrieval error: %s\n"%(repr(msg)))
				rtval = 1

		return rtval

	def send_mails(self, meta, toSend, ctx):
		"""sends individual mails to all recipients.

		ctx is a gpgme context.
		"""
		for recipient in self.recipients:
			try:
				self._send_one_mail(recipient, meta, toSend, ctx)
			except Exception, msg:
				send_failure_notice(self.admin_addr, repr(toSend), recipient)

	def _send_one_mail(self, recipient, meta, toSend, ctx):
		payload = StringIO(toSend.as_string())

		dest = StringIO()
		ctx.armor = True
		ctx.encrypt([get_key_for(recipient, ctx)], self.options, payload, dest)
		cypher = dest.getvalue()

		payload = MIMEApplication(_data=cypher,
				_subtype='octet-stream; name="encrypted.asc"',
				_encoder=encode_7or8bit)
		payload['Content-Description'] = 'OpenPGP encrypted message'
		payload.set_charset('us-ascii')

		control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
				_encoder=encode_7or8bit)
		control.set_charset('us-ascii')

		msg = MIMEMultipart('encrypted',
				micalg='pgp-sha1', protocol='application/pgp-encrypted')

		tag = "[%s]"%self.list_addr.split("@")[0]

		if "Subject" not in meta:
			meta["Subject"] = "(no subject)"
		if tag in meta["Subject"]:
			msg["Subject"] = meta["Subject"]
		else:
			msg["Subject"] = "%s %s"%(tag, meta["Subject"])

		msg["Date"] = meta["Date"]
		msg["From"] = meta["From"]
		msg["Reply-To"] = self.list_addr
		msg["To"] = recipient
		msg["List-Id"] = self.list_addr

		msg.attach(control)
		msg.attach(payload)
		msg['Content-Disposition'] = 'inline'

		send_mail(msg.as_string())


def parse_incoming(ctx, data):
	"""returns a pair of (meta, content) email.Message instance from data.

	This will apply decryption if possible.

	In case of PGP/MIME, meta is the envelope that contains Subject, Date,
	and such; in all other cases, meta and content are the same.
	"""
	parsed = Parser().parsestr(data)
	if parsed.get_content_subtype()=="encrypted":
		# PGP/MIME: the message is in the second part, and we need to 
		# decrypt it
		dest = StringIO()
		ctx.decrypt(StringIO(parsed.get_payload(1).get_payload()), dest)
		data = dest.getvalue()
		return parsed, Parser().parsestr(data)

	else:
		# unencrypted or inline PGP.  Iterate through payloads, guessing
		# if there's PGP material in there
		for part in parsed.walk():
			if (part.get_content_maintype() 
					and "-----BEGIN PGP MESSAGE" in part.get_payload()):
				try:
					dest = StringIO()
					ctx.decrypt(StringIO(part.get_payload(decode=True)), dest)
					del part["content-transfer-encoding"]
					part.set_payload(dest.getvalue())
					encode_base64(part)
				except gpgme.GpgmeError, msg:
					# If we can't decrypt it, leave it alone so people can have
					# a look at it.
					warnings.warn("Leaving alone a PGP part: %s"%msg)
		
		return parsed, parsed


def process_one_mail(msg, dest_list):
	"""returns a mail contained in the string msg formatted for encrypted
	resending over the MailingList list.
	"""
	ctx = gpgme.Context()
	meta, toSend = parse_incoming(ctx, msg)
	return dest_list.send_mails(meta, toSend, ctx)


def send_failure_notice(admin_addr, source_mail, recipient=None):
	"""sends out a failure notice about source_mail to admin_addr.

	This expects to be executed in an exception handler.
	"""
	dest = StringIO()
	traceback.print_exc(file=dest)
	payload = ["An error has occurred while processing the attached message:",
		"",
		dest.getvalue(),
		"",
		"Please take care of it.\n"]
	if recipient:
		payload.append("Attention: Failure happened specifically for recipient")
		payload.append(recipient)

	msg = MIMEMultipart()
	msg["Subject"] = "simple pgp exploder failure"
	msg["From"] = admin_addr
	msg["To"] = admin_addr
	msg.attach(MIMEText("\n".join(payload)))
	msg.attach(MIMEMessage(MIMEText(source_mail)))

	send_mail(msg.as_string())


def send_mail(mail_text):
	"""hands over mail_text as-is to sendmail.
	"""
	sendmail = subprocess.Popen(["/usr/sbin/sendmail", "-oi", "-odq", "-t"],
		stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
	sendmail.communicate(mail_text)


def parse_command_line():
	parser = argparse.ArgumentParser(description="Re-send a mail from stdin"
		" to multiple parties, PGP/MIME-encrypted")
	parser.add_argument("listconf", help="Path to the"
		" list configuration file")
	parser.add_argument("-t", help="Just run whatever _test does.",
		action="store_true", dest="test_only")
	parser.add_argument("-d", help="Dump incoming mail to ~/mail.dump",
		action="store_true", dest="dump_mail")
	parser.add_argument("-f", metavar="SRC",
		help="Read mail from SRC instead of stdin",
		dest="source_name", default=None)
	parser.add_argument("-c", "--check-list",
		help="Just check the list config and exit"
			" (0=list ok, 1=read diagnostics).",
		dest="check_list", action="store_true")

	return parser.parse_args()


def main():
	args = parse_command_line()
	if args.test_only:
		return _test(args)
	if args.check_list:
		return MailingList.verify_from_file(args.listconf)

	dest_list = MailingList.from_file(args.listconf)

	if args.source_name is None:
		source_mail = sys.stdin.read()
	else:
		with open(args.source_name) as f:
			source_mail = f.read()

	if args.dump_mail:
		for i in itertools.count():
			dump_name = os.path.expanduser("~/mail%03d.dump"%i)
			if not os.path.exists(dump_name):
				# don't worry about the race condition here
					with open(dump_name, "w") as f:
						f.write(source_mail)
					break

	try:
		process_one_mail(source_mail, dest_list)
	except:
		send_failure_notice(dest_list.admin_addr, source_mail)
		raise


def _test(args):
	"""runs a few plaubility tests.

	This only works on Markus' setup.
	"""
	dest_list = MailingList.from_file(args.listconf)
	res = crypt_one_mail("Date: Sat, 09 May 2015 16:52:08 +0200\nFrom: Foo Bar <foobar@example.com>\nMIME-Version: 1.0\nTo: datenschutzgruppe@rote-hilfe.de\nSubject: jaja\nContent-Type: text/plain; charset=utf-8; format=flowed\n\nJaja, und fertig.\n", dest_list)
	print res

if __name__=="__main__":
	try:
		main()
	except Exception, ex:
		send_failure_notice("root", "(did not get to process mail)\n"+
			("command line: %s\n"%sys.argv)+
			repr(ex))
		raise
