Simon Volpert remailer / master remailer.py
master

Tree @master (Download .tar.gz)

remailer.py @masterraw · history · blame

#!/usr/bin/python2 -B
# remailer - A PGP-enforcing newsletter implementation
# Author: Simon Volpert <simon@simonvolpert.com>
# Project page: https://simonvolpert.com/remailer/
# This program is free software, released under the Apache License, Version 2.0. See the LICENSE file for more information
# Consult the README file for usage instructions and other helpful hints

# -----===== CONFIGURATION =====-----
files = {
	'headers': '%s/headers.txt',  # Email headers to be included in sent messages
	'subscribers': '%s/subscribers.txt',  # List of newsletter subscribers
	'welcome': '%s/welcome.txt',  # The message sent when someone subscribes
	'farewell': '%s/farewell.txt',  # The message sent when someone unsubscribes
	'confirmation': '%s/confirm.txt',  # The message sent when someone requests subscription
	'details_missing': '%s/details_missing.txt',  # The message sent when a details request is made on a non-existent email
	'details_verified': '%s/details_verified.txt',  # The message sent when a details request is made on a verified email
	'details_pending': '%s/details_pending.txt',  # The message sent when a details request is made on an email pending verification
	'details_bounced': '%s/details_bounced.txt'  # The message sent when a details request is made on a disabled email
}
# See also remailer.cfg

import sys
import os
import email
import time
import subprocess
import string
import random
import copy
import logging

# Some web hosts are not very diligent in keeping up-to-date
try:
	from email.mime.text import MIMEText  # Python 2.7+
except ImportError:
	from email.MIMEText import MIMEText  # Python 2.4

try:
	from email.mime.multipart import MIMEMultipart  # Python 2.7+
except ImportError:
	from email.MIMEMultipart import MIMEMultipart  # Python 2.4

try:
	from email.utils import parseaddr  # Python 2.7+
except ImportError:
	from email.Utils import parseaddr  # Python 2.4

# Those are local scripts put in the same directory
import clearmime

# Abort the execution leaving sending the list owner a backtrace
def panic(error):
	logging.error('Panicking: %s' % error)
	debug_file = '%s.eml' % time.time()
	df = open(debug_file, 'w')
	df.write(raw_email)
	df.close()
	logging.error('Offending message saved to "%s"' % debug_file)
	sys.exit()

# Configuration file parsing and validation
# The simplicity of this implementation does not warrant employing a ConfigParser
conf = {}
try:
	c = open('remailer.cfg', 'r')
	for l in c.readlines():
		k, v = l.strip().split('=')[0], l.strip().split('=')[1:]
		if type(v) == type([]):
			v = '='.join(v)
		conf[k] = v
	c.close()
except:
	panic('No configuration file found')
for s in ['backend', 'server', 'login', 'passwd', 'allowed_senders', 'list_owner', 'gnupg_file']:
	if s not in conf:
		panic('`%s` key is missing from configuration file' % s)

# Send one or more pre-constructed emails
def sendmail(message_list):
	if type(message_list) != type([]):
		message_list = [message_list]
	sent_count = 0
	if conf['backend'] == 'smtp':
		import smtplib
		try:
			logging.info('Connecting to SMTP server')
			server = smtplib.SMTP(conf['server'])
			server.login(conf['login'], conf['passwd'])
		except:
			panic('SMTP failed: %s' % sys.exc_info()[0])
		for message in message_list:
			try:
				server.sendmail(message['From'], message['To'], message.as_string())
			except:
				logging.error('Sending to %s failed: %s' % (message['To'], sys.exc_info()[0]))
			else:
				sent_count += 1
				#logging.debug('Sent to %s' % message['To'])
		server.quit()
	elif conf['backend'] == 'sendmail':
		for message in message_list:
			try:
				server = subprocess.Popen(['/usr/sbin/sendmail','-i', message['To']], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
				_, stderr = server.communicate(message.as_string())
				if stderr != '':
					logging.error('Sending to %s failed' % message['To'])
					logging.error(stderr)
			except:
				logging.error('Sendmail failed: %s' % sys.exc_info()[0])
			else:
				sent_count += 1
	elif conf['backend'] == 'dummy':
		logging.info('Pretending to send messages')
	else:
		panic('Back-end for sending mail is not configured')
	if sent_count == 0:
		panic('No valid recipients for %s' % list_name)
	else:
		logging.info('%s message(s) sent' % sent_count)


# Extract a relevant record from the subscriber database
def find_subscriber(subscriber):
	try:
		rfile = open(files['subscribers'])
	except:
		panic('Could not load subscriber list for %s: %s' % (list_name, sys.exc_info()[0]))
	else:
		for raw_recipient in rfile:
			if raw_recipient == '\n':
				continue
			recipient = raw_recipient.strip().split('	')
			r_name, r_email = parseaddr(recipient[0])
			if r_email == subscriber:
				rfile.close()
				return recipient
		return None

# Extract the message sender
def get_sender(message):
	address = None
	for header in ['From', 'From_', 'Reply-To']:
		if not message[header] == None:
			_, address = parseaddr(message[header])
			return address
	panic('Could not find the sender address')

# Load a file as message
def message_from_file(filename, args=()):
	try:
		wfile = open(filename)
	except:
		panic('Could not load %s: %s' % (filename, sys.exc_info()[0]))
	else:
		message = MIMEText(wfile.read() % args)
		wfile.close()
	message['From'] = '%s-request@simonvolpert.com' % list_name
	message['Auto-Submitted'] = 'auto-responded'
	return message

# Send a report about a subscriber
def report(address):
	subscriber = find_subscriber(address)
	if subscriber == None:
		message = message_from_file(files['details_missing'] , address)
		message['Subject'] = 'Email not found'
	else:
		if subscriber[3] == 'verified':
			message = message_from_file(files['details_verified'], (subscriber[0], subscriber[1], subscriber[2], subscriber[4]))
			message['Subject'] = 'Subscription details'
		elif subscriber[3] == 'bounce':
			message = message_from_file(files['details_bounced'], (subscriber[0], subscriber[1], subscriber[2]))
			message['Subject'] = 'Subscription disabled'
		else:
			message = message_from_file(files['details_pending'], (subscriber[0], subscriber[1], subscriber[2]))  # TODO make it double as a re-subscription, maybe
			message['Subject'] = 'Verification pending'
	message['To'] = address
	sendmail(insert_headers(message))
	sys.exit()

# Insert mailing-list headers into the message
def insert_headers(message):
	try:
		hfile = open(files['headers'])
	except:
		panic('Could not load header list for %s: %s' % (list_name, sys.exc_info()[0]))
	else:
		for line in hfile:
			header, header_text = line.strip().split('|')
			message[header] = header_text
		hfile.close()
	return message


# -----===== EMAIL PROCESSING =====-----
os.chdir(sys.path[0])

logging.basicConfig(filename='remailer.log', level=logging.DEBUG, format='%(asctime)s  %(message)s')

# This script works on emails piped into it
raw_email = sys.stdin.read()
message = email.message_from_string(raw_email)

logging.info('Incoming message from %s (%s bytes)' % (message['From'], len(raw_email)))

# Figure out the list name from the message headers
# Recipients checked in reverse order of directness
list_name = None
for header in ['To', 'CC', 'BCC']:
	if not message[header] == None:
		_, list_name = parseaddr(message[header])
if list_name == None:
	panic('Could not figure out the list name')
else:
	list_name = list_name.split('@')[0]
	if list_name.endswith('-request'):
		list_name = list_name.split('-')[0]
		request = True
		logging.info('REQUEST mode on "%s"' % list_name)
	else:
		request = False
		logging.info('POST mode on "%s"' % list_name)
	for item in files:
		files[item] = files[item] % list_name
	# Delete original message recipients and bounce-to headers
	for header in ['To', 'CC', 'BCC', 'Resent-To', 'Resent-From', 'Resent-Date', 'Resent-Message-ID']:
		if not message[header] == None:
			del message[header]

# -----===== SUBSCRIPTION MANAGEMENT =====-----
if request:
	# If it's an auto-response, discard it
	if message['Auto-Submitted'] != None:
		panic('Message is an auto-response')
	# -----===== SUBSCRIBE =====-----
	logging.info('Subject: %s' % message['Subject'])
	if message['Subject'].lower().startswith('subscribe'):
		message_from = get_sender(message)
		subscriber = find_subscriber(message_from)
		if not subscriber == None:
			if subscriber[3] == 'verified':
				report(subscriber[0])
			else:
				# Comment out for dry-run
				subprocess.call(['sed', '-i', '-e', '/%s/d' % subscriber[0], files['subscribers']])
				logging.info('Subscriber info UPDATED for %s' % subscriber[0])
		now = time.asctime(time.gmtime())
		medium = 'email'
		subscriber = [message_from, now, medium, '']
		subscriber[3] = ''.join(random.choice(string.ascii_lowercase + string.digits) for x in range(8))
		# Comment out for dry-run
		sfile = open(files['subscribers'], 'a')
		sfile.write('	'.join(subscriber) + '\n')
		sfile.close()
		logging.info('Subscriber info WRITTEN for %s' % subscriber[0])
		message = message_from_file(files['confirmation'], (message_from, now, medium))
		message['To'] = subscriber[0]
		message['Subject'] = 'Confirm your subscription - %s' % subscriber[3]
		sendmail(insert_headers(message))
	# -----===== UNSUBSCRIBE =====-----
	elif message['Subject'].lower().startswith('unsubscribe'):
		message_from = get_sender(message)
		subscriber = find_subscriber(message_from)
		if subscriber == None:
			report(message_from)
		else:
			# Comment out for dry-run
			subprocess.call(['sed', '-i', '-e', '/%s/d' % subscriber[0], files['subscribers']])
			logging.info('Subscriber info REMOVED for %s' % subscriber[0])
			message = message_from_file(files['farewell'], message_from)
			message['To'] = subscriber[0]
			message['Subject'] = 'Unsubscription successful'
			sendmail(insert_headers(message))
	# -----===== DETAILS =====-----
	elif message['Subject'].lower().startswith('details') or message['Subject'].lower().startswith('status') or message['Subject'].lower().startswith('info'):
		report(get_sender(message))
	# -----===== CONFIRM =====-----
	elif message['Subject'].lower().startswith('re: confirm '):
		message_from = get_sender(message)
		subscriber = find_subscriber(message_from)
		if not subscriber == None:
			if subscriber[3] == 'verified' or subscriber[3] == 'bounce':
				report(subscriber[0])
		confirm_string = message['Subject'].split(' ')[-1]
		message_from = get_sender(message)
		subscriber = find_subscriber(message_from)
		if subscriber == None:
			report(message_from)
		else:
			if confirm_string == subscriber[3]:
				# Comment out for dry-run
				subprocess.call(['sed', '-i', '-e', 's/%s.*/verified	%s/' % (confirm_string, time.asctime(time.gmtime())), files['subscribers']])
				logging.info('Subscriber info UPDATED for %s' % subscriber[0])
				message = message_from_file(files['welcome'])
				message['To'] = subscriber[0]
				message['Subject'] = 'Confirmation successful'
				sendmail(insert_headers(message))
			else:
				panic('Confirmation string does not match')
	else:
		panic('Unknown command')
# -----===== PUBLISHING =====-----
else:
	# Bounce unknown senders # TODO
#	if get_sender(message) not in conf.allowed_senders:
#		panic('Unauthorized sender: %s' % message['From'])
	if message['From'] != conf['list_owner']:
		panic('Unauthorized sender: %s' % message['From'])

	# Verify the message sender's GPG signature
	clear_email = clearmime.clarify(raw_email)
	server = subprocess.Popen(['gpgv', '--keyring', conf['gnupg_file']], stdin=subprocess.PIPE, stderr=subprocess.PIPE)
	_, stderr = server.communicate(clear_email)
	if not ( 'gpgv: Good signature from "%s"' % conf['list_owner'] in stderr ): # TODO expand to all allowed-senders
		logging.info(stderr)
		panic('GPG Signature verification failed')
	logging.info('Signature check successful')

	message = insert_headers(message)
	# Re-send the email to every verified subscriber from the subscriber list
	# Format: email, subscription date, subscription medium, status, confirmation date (tab separated)
	try:
		rfile = open(files['subscribers'])
	except:
		panic('Could not load subscriber list for %s: %s' % (list_name, sys.exc_info()[0]))
	message_list = []
	for recipient in rfile:
		if recipient == '\n':
			continue
		recipient = recipient.strip().split('	')
		if not recipient[3] == 'verified':
			continue
		del message['To']
		message['To'] = recipient[0]
		message_list.append(copy.copy(message))
	rfile.close()
	sendmail(message_list)