#!/usr/bin/env python
# Script to send mass email
#
# Copyright (C) 2003-2017 Tiziano Zito <opossumnano@gmail.com>, Jakob Jordan <jakobjordan@posteo.de>
#
# This script is free software and comes without any warranty, to
# the extent permitted by applicable law. You can redistribute it
# and/or modify it under the terms of the Do What The Fuck You Want
# To Public License, Version 2, as published by Sam Hocevar.
# http://www.wtfpl.net
#
# Full license text:
#
# DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
# Version 2, December 2004.
#
# Everyone is permitted to copy and distribute verbatim or modified
# copies of this license document, and changing it is allowed as long
# as the name is changed.
#
# DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
# TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
#
# 0. You just DO WHAT THE FUCK YOU WANT TO.

import smtplib, getopt, sys, os, email, getpass
import email.header
import email.mime.text
import email.utils
import io
import hashlib
import contextlib
import tempfile
import time
import subprocess
import base64
import re

PROGNAME = os.path.basename(sys.argv[0])
USAGE = """Send mass mail
Usage:
  %s [...] PARAMETER_FILE < BODY

Options:
  -F FROM           set the From: header for all messages.
                    Must be ASCII. This argument is required

  -S SUBJECT        set the Subject: header for all messages

  -B BCC            set the Bcc: header for all messages.
                    Must be ASCII

  -C CC             set the Cc: header for all messages.
                    Must be ASCII

  -R In-Reply-To    set the In-Reply-To: takes a Message-ID as input

  -s SEPARATOR      set field separator in parameter file,
                    default: ";"

  -e ENCODING       set PARAMETER_FILE *and* BODY character set
                    encoding, default: "UTF-8". Note that if you fuck
                    up this one, your email will be full of rubbish:
                    You have been warned!

  -z SERVER         the SMTP server to use. This argument is required

  -P PORT           the SMTP port to use. Must be between 1 and 65535.

  -u                SMTP user name. If not set, use anonymous SMTP
                    connection

  -p                SMTP password. If not set you will be prompted for one

  -h                show this usage message

Notes:
  The message body is read from standard input or
  typed in interactively (exit with ^D) and keywords are subsituted with values
  read from a parameter file. The first line of the parameter file defines
  the keywords. The keyword $EMAIL$ must always be present and contains a comma
  separated list of email addresses.
  Keep in mind shell escaping when setting headers with white spaces or special
  characters.
  Character set encodings are those supported by python.

Examples:

* Example of a parameter file:

$NAME$; $SURNAME$; $EMAIL$
John; Smith; j@guys.com
Anne; Joyce; a@girls.com

* Example of body:

Dear $NAME$ $SURNAME$,

I think you are a great guy/girl!

Cheers,

My self.

* Example usage:

%s  -F "Great Guy <gg@guys.com>" -S "You are a great guy" -B "Not so great Guy <ngg@guys.com>" parameter-file < body

"""%(PROGNAME, PROGNAME)

def error(s):
    sys.stderr.write(PROGNAME+': ')
    sys.stderr.write(s+'\n')
    sys.stderr.flush()
    sys.exit(-1)

def parse_command_line_options(arguments):
    """Parse options.

    Returns a dictionary of options.

    Arguments are checked for validity.
    """
    try:
        opts, args = getopt.getopt(arguments, "hs:F:S:B:C:R:e:u:p:P:z:")
    except getopt.GetoptError as err:
        error(str(err)+USAGE)

    # set default options
    options = {
        'sep': u';',
        'from': '',
        'subject': '',
        'bcc': '',
        'cc': '',
        'encoding': 'utf-8',
        'smtpuser': None,
        'smtppassword': None,
        'server': None,
        'port': 0,
        'in_reply_to': '',
    }

    for option, value in opts:
        if option == "-e":
            options['encoding'] = value
        if option == "-s":
            options['sep'] = value
        elif option == "-F":
            options['from'] = value
        elif option == "-S":
            options['subject'] = value
        elif option == "-B":
            options['bcc'] = value
        elif option == "-C":
            options['cc'] = value
        elif option == "-R":
            options['in_reply_to'] = value
        elif option == "-h":
            print(USAGE)
            exit(0)
        elif option == "-u":
            options['smtpuser'] = value
        elif option == "-p":
            options['smtppassword'] = value
        elif option == "-P":
            options['port'] = int(value)
        elif option == "-z":
            options['server'] = value

    if len(args) == 0:
        error('You must specify a parameter file')

    if len(options['from']) == 0:
        error('You must set a from address with option -F')

    if options['server'] is None:
        error('You must set a SMTP server with option -z')

    if options['sep'] == ",":
        error('Separator can not be a comma')

    # set filenames of parameter and mail body
    options['fn_parameters'] = args[0]

    return options

def parse_parameter_file(options):
    pars_fh = open(options['fn_parameters'],'rt')
    pars = pars_fh.read()
    pars_fh.close()

    if options['in_reply_to'] and not options['in_reply_to'].startswith('<'):
        options['in_reply_to'] = '<{}>'.format(options['in_reply_to'])

    # split lines
    pars = pars.splitlines()

    # get keywords from first line
    keywords_list = [key.strip() for key in pars[0].split(options['sep'])]

    # fail immediately if no EMAIL keyword is found
    if '$EMAIL$' not in keywords_list:
        error('No $EMAIL$ keyword found in %s'%options['fn_parameters'])

    # check that all keywords start and finish with a '$' character
    for key in keywords_list:
        if not key.startswith('$') or not key.endswith('$'):
            error('Keyword "%s" malformed: should be $KEYWORD$'%key)

    # gather all values
    email_count = 0
    keywords = dict([(key, []) for key in keywords_list])
    for count, line in enumerate(pars[1:]):
        # ignore empty lines
        if len(line) == 0:
            continue
        values = [key.strip() for key in line.split(options['sep'])]
        if len(values) != len(keywords_list):
            error(('Line %d in "%s" malformed: %d values found instead of'
                   ' %d: %s'%(count+1,options['fn_parameters'],len(values),len(keywords_list),line)).encode(options['encoding']))
        for i, key in enumerate(keywords_list):
            keywords[key].append(values[i])
        email_count += 1

    return keywords, email_count

def create_email_bodies(options, keywords, email_count, body):
    # find keywords and substitute with values
    # we need to create email_count bodies
    msgs = {}

    for i in range(email_count):
        lbody = re.sub(r'\$\w+\$', lambda m: keywords[m.group(0)][i], body)

        # encode body
        lbody = email.mime.text.MIMEText(lbody.encode(options['encoding']), 'plain', options['encoding'])
        msgs[keywords['$EMAIL$'][i]] = lbody

    return msgs

def add_email_headers(options, msgs):
    # msgs is now a dictionary with {emailaddr:body}
    # we need to add the headers

    for emailaddr in msgs:
        msg = msgs[emailaddr]
        msg['To'] = str(emailaddr)
        msg['From'] = email.header.Header(options['from'])
        if options['subject']:
            msg['Subject'] = email.header.Header(options['subject'].encode(options['encoding']), options['encoding'])
        if options['in_reply_to']:
            msg['In-Reply-To'] = email.header.Header(options['in_reply_to'])
        if options['cc']:
            msg['Cc'] = email.header.Header(options['cc'])
        # add the required date header
        msg['Date'] = email.utils.formatdate(localtime=True)
        # add a unique message-id
        msg['Message-ID'] = email.header.Header(generate_unique_id(msg))
        msgs[emailaddr] = msg

    return None

def generate_unique_id(msg):
    # Get headers and body in the message, hash it and convert to base64
    prefix = hashlib.sha256(msg.get_payload(decode=True)).hexdigest()[:16]
    message_id = email.utils.make_msgid(idstring=prefix)
    return message_id

def send_messages(options, msgs):

    for emailaddr in msgs:
        emails = [e.strip() for e in emailaddr.split(',')]
        if len(options['bcc']) > 0:
            emails.append(options['bcc'])
        print('This email will be sent to:', ', '.join(emails))
        [print(hdr+':', value) for hdr, value in msgs[emailaddr].items()]
        print()
        print(msgs[emailaddr].get_payload(decode=True).decode(options['encoding']))

    # ask for confirmation before really sending stuff
    # we need to read input from the terminal, because stdin is taken already
    sys.stdin = open('/dev/tty')
    ans = input('Send the emails above? Type "Y" to confirm! ')
    if ans != 'Y':
        error('OK, exiting without sending anything!')

    print()

    server = smtplib.SMTP(options['server'], port=options['port'])

    if options['smtpuser'] is not None:
        try:
            server.starttls()
            # get password if needed
            if  options['smtppassword'] is None:
                options['smtppassword'] = getpass.getpass('Enter password for %s: '%options['smtpuser'])
            server.login(options['smtpuser'], options['smtppassword'])
        except Exception as err:
            error(str(err))

    print()

    for emailaddr in msgs:
        print('Sending email to:', emailaddr)
        emails = [e.strip() for e in emailaddr.split(',')]
        if len(options['bcc']) > 0:
            emails.append(options['bcc'])
        try:
            out = server.sendmail(options['from'], emails, msgs[emailaddr].as_string())
        except Exception as err:
            error(str(err))

        if len(out) != 0:
            error(str(out))

    server.close()

def main(arguments):
    options = parse_command_line_options(arguments)
    keywords, email_count = parse_parameter_file(options)
    msgs = create_email_bodies(options, keywords, email_count, sys.stdin.read())
    add_email_headers(options, msgs)
    send_messages(options, msgs)

if __name__ == '__main__':
    main(sys.argv[1:])
