409 lines
14 KiB
Python
409 lines
14 KiB
Python
#!/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 tempfile
|
|
|
|
from py.test import raises
|
|
|
|
|
|
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
|
|
|
|
-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!
|
|
|
|
-f fake run: don't really send emails, just print to
|
|
standard output what would be done. Don't be scared
|
|
if you can not read the body: it is base64 encoded
|
|
UTF8 text
|
|
|
|
-z SERVER the SMTP server to use. This argument is required
|
|
|
|
-P PORT the SMTP port to use.
|
|
|
|
-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
|
|
try:
|
|
opts, args = getopt.getopt(arguments, "hfs:F:S:B:R:e:u:p:P:z:")
|
|
except getopt.GetoptError, err:
|
|
error(str(err)+USAGE)
|
|
|
|
# set default options
|
|
options = {
|
|
'sep': u';',
|
|
'fake': False,
|
|
'from': '',
|
|
'subject': '',
|
|
'bcc': '',
|
|
'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 == "-R":
|
|
options['in_reply_to'] = value
|
|
elif option == "-h":
|
|
error(USAGE)
|
|
elif option == "-f":
|
|
options['fake'] = True
|
|
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')
|
|
|
|
# get password if needed
|
|
if options['smtpuser'] is not None and options['smtppassword'] is None:
|
|
options['smtppassword'] = getpass.getpass('Enter password for %s: '%options['smtpuser'])
|
|
|
|
# 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'], 'rbU')
|
|
pars = pars_fh.read()
|
|
pars_fh.close()
|
|
|
|
try:
|
|
pars = unicode(pars, encoding=options['encoding'])
|
|
except UnicodeDecodeError, e:
|
|
error('Error decoding "'+options['fn_parameters']+'": '+str(e))
|
|
|
|
try:
|
|
options['subject'] = unicode(options['subject'], encoding=options['encoding'])
|
|
except UnicodeDecodeError, e:
|
|
error('Error decoding SUBJECT: '+str(e))
|
|
|
|
try:
|
|
options['from'] = unicode(options['from'], encoding=options['encoding'])
|
|
except UnicodeDecodeError, e:
|
|
error('Error decoding FROM: '+str(e))
|
|
|
|
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).encode(options['encoding']))
|
|
|
|
# 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):
|
|
try:
|
|
body = unicode(body, encoding=options['encoding'])
|
|
except UnicodeDecodeError, e:
|
|
error('Error decoding email body: '+str(e))
|
|
|
|
# find keywords and substitute with values
|
|
# we need to create email_count bodies
|
|
msgs = {}
|
|
|
|
for i in range(email_count):
|
|
lbody = body
|
|
for key in keywords:
|
|
lbody = lbody.replace(key, keywords[key][i])
|
|
|
|
# Any single dollar left? That means that the body was malformed
|
|
single_dollar_exists = lbody.count('$') != 2 * lbody.count('$$')
|
|
if single_dollar_exists:
|
|
raise ValueError('Malformed email body: unclosed placeholder or non-escaped dollar sign.')
|
|
|
|
# Replace double dollars with single dollars
|
|
lbody = lbody.replace('$$', '$')
|
|
|
|
# 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'])
|
|
msgs[emailaddr] = msg
|
|
|
|
return None
|
|
|
|
def send_messages(options, msgs):
|
|
server = smtplib.SMTP(options['server'], port=options['port'])
|
|
|
|
if options['smtpuser'] is not None:
|
|
server.starttls()
|
|
server.login(options['smtpuser'], options['smtppassword'])
|
|
|
|
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'])
|
|
if options['fake']:
|
|
print msgs[emailaddr].as_string()
|
|
else:
|
|
try:
|
|
out = server.sendmail(options['from'], emails, msgs[emailaddr].as_string())
|
|
except Exception, err:
|
|
error(str(err))
|
|
|
|
if len(out) != 0:
|
|
error(str(out))
|
|
|
|
server.close()
|
|
|
|
def test_dummy():
|
|
pass
|
|
|
|
def test_parse_parameter_file():
|
|
expected_keywords = {u'$VALUE$': [u'this is a test'], u'$EMAIL$': [u'testrecv@test']}
|
|
with tempfile.NamedTemporaryFile() as f:
|
|
f.write('$EMAIL$;$VALUE$\ntestrecv@test;this is a test')
|
|
f.flush()
|
|
cmd_options = [
|
|
'-F', 'testfrom@test',
|
|
'-z', 'localhost',
|
|
f.name,
|
|
]
|
|
options = parse_command_line_options(cmd_options)
|
|
keywords, email_count = parse_parameter_file(options)
|
|
assert keywords == expected_keywords
|
|
|
|
def test_local_sending():
|
|
parameter_string = '$EMAIL$;$NAME$;$VALUE$\ntestrecv@test.org;TestName;531'
|
|
email_body = 'Dear $NAME$,\nthis is a test: $VALUE$\nBest regards'
|
|
email_to = 'testrecv@test.org'
|
|
email_from = 'testfrom@test.org'
|
|
email_subject = 'Test Subject'
|
|
email_encoding = 'utf-8'
|
|
|
|
expected_email = email.mime.text.MIMEText('Dear TestName,\nthis is a test: 531\nBest regards'.encode(email_encoding), 'plain', email_encoding)
|
|
expected_email['To'] = email_to
|
|
expected_email['From'] = email_from
|
|
expected_email['Subject'] = email.header.Header(email_subject.encode(email_encoding), email_encoding)
|
|
|
|
with tempfile.NamedTemporaryFile() as f:
|
|
f.write(parameter_string)
|
|
f.flush()
|
|
cmd_options = [
|
|
'-F', email_from,
|
|
'-S', email_subject,
|
|
'-z', 'localhost',
|
|
'-e', email_encoding,
|
|
f.name
|
|
]
|
|
options = parse_command_line_options(cmd_options)
|
|
keywords, email_count = parse_parameter_file(options)
|
|
msgs = create_email_bodies(options, keywords, email_count, email_body)
|
|
add_email_headers(options, msgs)
|
|
assert msgs['testrecv@test.org'].as_string() == expected_email.as_string()
|
|
|
|
def test_malformed_body():
|
|
parameter_string = '$EMAIL$;$NAME$;$VALUE$\ntestrecv@test.org;TestName;531'
|
|
email_body = '$NAME$VALUE$'
|
|
email_from = 'testfrom@test.org'
|
|
email_subject = 'Test Subject'
|
|
email_encoding = 'utf-8'
|
|
|
|
with tempfile.NamedTemporaryFile() as f:
|
|
f.write(parameter_string)
|
|
f.flush()
|
|
cmd_options = [
|
|
'-F', email_from,
|
|
'-S', email_subject,
|
|
'-z', 'localhost',
|
|
'-e', email_encoding,
|
|
f.name
|
|
]
|
|
options = parse_command_line_options(cmd_options)
|
|
keywords, email_count = parse_parameter_file(options)
|
|
with raises(ValueError):
|
|
msgs = create_email_bodies(options, keywords, email_count, email_body)
|
|
|
|
def test_double_dollar():
|
|
parameter_string = '$EMAIL$;$NAME$;$VALUE$\ntestrecv@test.org;TestName;531'
|
|
email_body = 'Dear $NAME$,\nyou owe us 254$$'
|
|
email_to = 'testrecv@test.org'
|
|
email_from = 'testfrom@test.org'
|
|
email_subject = 'Test Subject'
|
|
email_encoding = 'utf-8'
|
|
|
|
expected_email = email.mime.text.MIMEText('Dear TestName,\nyou owe us 254$'.encode(email_encoding), 'plain', email_encoding)
|
|
expected_email['To'] = email_to
|
|
expected_email['From'] = email_from
|
|
expected_email['Subject'] = email.header.Header(email_subject.encode(email_encoding), email_encoding)
|
|
|
|
with tempfile.NamedTemporaryFile() as f:
|
|
f.write(parameter_string)
|
|
f.flush()
|
|
cmd_options = [
|
|
'-F', email_from,
|
|
'-S', email_subject,
|
|
'-z', 'localhost',
|
|
'-e', email_encoding,
|
|
f.name
|
|
]
|
|
options = parse_command_line_options(cmd_options)
|
|
keywords, email_count = parse_parameter_file(options)
|
|
msgs = create_email_bodies(options, keywords, email_count, email_body)
|
|
assert msgs['testrecv@test.org'].get_payload(decode=email_encoding) == expected_email.get_payload(decode=email_encoding)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
options = parse_command_line_options(sys.argv[1:])
|
|
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)
|