1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
|
#! /usr/bin/env python
"""Send personalised email to a list of recipients.
Personalisation fields come from a CSV file.
"""
import codecs
import csv
import os
import pwd
import smtplib
import sys
from getpass import getpass
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, parseaddr
from optparse import OptionParser
from string import Template
COLON = ':'
SPACE = ' '
MAXLINELEN = 72
def send_one_email(parser, from_header, mail_from, message_template,
firstname, displayname, email, other_data, lineno):
# Start by interpolating values into the message template.
message_text = message_template.substitute(
other_data,
firstname=firstname,
displayname=displayname,
email=email)
# Calculate the To: header. If the header is ASCII add it the 'pretty'
# way. otherwise add it the safe way.
try:
displayname.encode('us-ascii')
to_header = formataddr((displayname, email))
except UnicodeError:
# It's too bad we can't really use formataddr()
# here, because it doesn't play nicely with RFC 2231.
to_header = Header()
to_header.append(displayname.encode('utf-8'), 'utf-8')
to_header.append('<%s>' % email)
# Create a text/plain message. We do the same thing as above; if the
# message is pure ASCII, send it pretty, otherwise send it safe.
try:
message_text.encode('us-ascii')
msg = MIMEText(message_text)
except UnicodeError:
msg = MIMEText(message_text.encode('utf-8'), _charset='utf-8')
msg['From'] = from_header
msg['To'] = to_header
msg['Subject'] = parser.options.subject
msg['Date'] = formatdate()
# If this is a test mailing, use the test email as the RCPT TO. Otherwise
# use the real recipient address.
if parser.options.really_spam:
rcpt_to = email
elif parser.options.test_email == '-':
rcpt_to = None
else:
rcpt_to = parser.options.test_email
print ('%4d: mailing: %s at %s via %s' % (
lineno, firstname, email, rcpt_to)).encode('utf-8')
if rcpt_to is None:
print msg.as_string()
return
# Send the message.
s = smtplib.SMTP()
if parser.options.smtp is None:
s.connect()
else:
host, port = parser.options.smtp
s.connect(host, port)
s.ehlo()
if parser.options.tls:
s.starttls()
if parser.options.auth:
s.login(parser.username, parser.password)
# IF YOU'RE REALLY PARANOID, COMMENT THESE NEXT LINES OUT!
try:
s.sendmail(mail_from, [rcpt_to], msg.as_string())
except smtplib.SMTPException, error:
print >> sys.stderr, 'FAILED:', error
# Keep going!
s.close()
def send_all_emails(parser, from_header, mail_from, message_template):
with file(parser.address_path) as address_file:
reader = csv.DictReader(address_file)
for index, record in enumerate(reader):
# There are unicode names in the address file.
for name, value in record.items():
record[name] = value.decode('utf-8')
email = record.get('email')
displayname = record.get('display_name')
if email is None or displayname is None:
parser.error(
'Addresses should contain at least one email and '
'one display_name field.')
# Try to find a first name in the display name.
if SPACE in displayname:
firstname, ignore = displayname.split(SPACE, 1)
firstname = firstname.capitalize()
else:
firstname = displayname
# Remember that enumerate starts from 0
lineno = index + 1
send_one_email(parser, from_header, mail_from, message_template,
firstname, displayname, email, record, lineno)
def server_callback(option, opt, value, parser):
"""Parse (host, port) from option string."""
if COLON in value:
host, port = value.split(COLON, 1)
if host == '':
host = 'localhost'
if port == '':
port = 0
port = int(port)
else:
# No port given. 0 tells smtplib to use the system default.
host = value
port = 0
setattr(parser.values, option.dest, (host, port))
def parse_arguments():
parser = OptionParser(usage="""\
%prog [OPTIONS] addresses msg
'addresses' is a CSV file containing the recipients of the mailing. It should
contain the field names on the first line and it should have at least an
email and a display_name field.
'msg' is the file containing the email message to send. This file may
contain $-string substitutions for the following keys:
$firstname -- The user's first name
$displayname -- The user's display name
$email -- The user's email address
$any_other -- The any_other field from the CSV file related to the
email.
Each user in the 'addresses' file receives a completely independent and
personalized email message.
""")
parser.add_option('-t', '--test-email',
type='string',
help="""\
The test destination email address for dry runs. Use '-' to print the message
to standard out instead of emailing it.""")
parser.add_option('--really-spam',
action='store_true', default=False,
help='Send the emails to the real addresses.')
parser.add_option('--subject',
type='string',
help='The subject line of the email message.')
parser.add_option('--auth',
action='store_true', default=False,
help='''\
Prompt for the SMTP username and password.
Available at https://wiki.canonical.com/EmailSetup''')
parser.add_option('--tls',
action='store_true', default=False,
help='Use TLS to encrypt SMTP traffic.')
parser.add_option('--smtp',
type='string', default=None,
action='callback', callback=server_callback,
help="""\
hostname:port for the SMTP server to use. Both the hostname and the port part
is optional, with localhost being the default hostname, and 25 being the
default port. If neither is given localhost:25 is used.""")
parser.add_option('-f', '--from',
type='string', dest='from_header', help="""\
The From: header for the message. If not given, a default will be calculated
using the user's login name and id.""")
parser.add_option('--help-sql',
action='store_true', default=False,
help="""\
Print the SQL command to run on staging to get beta users.""")
parser.add_option('-m', '--maxlinelen',
type='int', default=MAXLINELEN, help="""\
Maximum line length limit on the announcement message. Use 0 to indicate that
there is no maximum line length limit.""")
options, arguments = parser.parse_args()
address_path = message_path = None
if options.help_sql:
print ('psql -d launchpad_staging -h asuka -U ro '
'-c "select Person.name, Person.displayname AS display_name, '
'EmailAddress.email '
'from Person, TeamParticipation, EmailAddress '
'where Person.id=EmailAddress.person and '
'Person.id = TeamParticipation.person and '
'TeamParticipation.team = 974364 and '
'EmailAddress.status=4 AND '
'Person.teamowner IS NULL order by Person.name;" '
'-e -F , -A > beta-members.txt')
sys.exit(0)
if len(arguments) < 2:
parser.error('Not enough arguments')
# Does not return.
elif len(arguments) > 2:
parser.error('Too many arguments')
# Does not return.
else:
address_path = arguments[0]
message_path = arguments[1]
if not options.really_spam and not options.test_email:
parser.error(
'You must specify a test address if this is not a real run.')
# Does not exit.
if options.really_spam and options.test_email:
parser.error('Do not specify a test address if it is a real run.')
# Does not exit.
if not options.subject:
parser.error('--subject is required')
# Does not exit.
# Convenience
parser.options = options
parser.arguments = arguments
parser.address_path = address_path
parser.message_path = message_path
if options.auth:
print
print "Enter SMTP auth credentials:"
print "Available at https://wiki.canonical.com/EmailSetup"
print
parser.username = raw_input(' Username: ')
parser.password = getpass(' Password: ')
return parser
def main():
parser = parse_arguments()
# Get the email message template.
with open(parser.message_path) as message_file:
raw_message_text = message_file.read()
# Check line lengths.
longest_line = len(max(raw_message_text.splitlines(), key=len))
if parser.options.maxlinelen > 0 and (
longest_line > parser.options.maxlinelen):
# A line is over our maximum.
parser.error('Message text has a line over the %d limit' %
parser.options.maxlinelen)
# Does not return.
message_template = Template(raw_message_text)
# Calculate the From header if not given.
if parser.options.from_header:
from_header = parser.options.from_header
else:
pwd_record = pwd.getpwuid(os.getuid())
from_name = pwd_record.pw_gecos.split(',')[0]
from_email = pwd_record.pw_name + '@canonical.com'
from_header = formataddr((from_name, from_email))
# The MAIL FROM is the address in the From header.
mail_from = parseaddr(from_header)[1]
assert mail_from, 'MAIL FROM value is empty'
send_all_emails(parser, from_header, mail_from, message_template)
if __name__ == '__main__':
main()
|