/
pydotmailer.py
489 lines (466 loc) · 26.4 KB
/
pydotmailer.py
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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# pydotmailer - A lightweight wrapper for the dotMailer API, written in Python.
# Copyright (c) 2012 Triggered Messaging Ltd, released under the MIT license
# Home page:
# https://github.com/TriggeredMessaging/pydotmailer/
# See README and LICENSE files.
#
# dotMailer API docs are at http://www.dotmailer.co.uk/api/
# This class was influenced by earllier work: https://github.com/JeremyJones/dotmailer-client/blob/master/dotmailer.py
import base64
import time
from datetime import datetime, timedelta
from suds.client import Client as SOAPClient
__version__ = '0.1.2'
try:
import simplejson as json
except ImportError:
import json # fall back to traditional json module.
import logging
logger = logging.getLogger(__name__)
from dotmailersudsplugin import DotMailerSudsPlugin
class PyDotMailer(object):
version = '0.1'
class RESULT_FIELDS_ERROR_CODE:
"""
Defines for RESULT_FIELDS.ERROR_CODE error codes which we're deriving from the string the ESP
e.g dotMailer returns.
"""
ERROR_CAMPAIGN_NOT_FOUND = 'ERROR_CAMPAIGN_NOT_FOUND' # no email template
ERROR_CAMPAIGN_SENDNOTPERMITTED = 'ERROR_CAMPAIGN_SENDNOTPERMITTED'
# not paid enough? dotMailer tends to return this if you've run out of campaign credits or a similar issue.
ERROR_CAMPAIGN_APINOTPERMITTED = 'ERROR_CAMPAIGN_APINOTPERMITTED' # e,g, exceeded dotmailer API limits (API_USAGE_EXCEEDED)
ERROR_GENERIC = 'ERROR_UNKNOWN' # code which couldn't be parsed.
ERROR_CONTACT_NOT_FOUND = 'ERROR_CONTACT_NOT_FOUND' # no email address?
ERROR_CONTACT_UNSUBSCRIBED = 'ERROR_CONTACT_UNSUBSCRIBED' # no send permission
ERROR_CONTACT_BLACKHOLED = 'ERROR_CONTACT_BLACKHOLED' # address blackholed
ERROR_OTHER = 'ERROR_OTHER' # Etc
TIMEOUT_ERROR = 'Timeout Error' # Timeout from ESP
ERROR_UNFINISHED = "ERROR_UNFINISHED" # Load had not finished
ERROR_ESP_LOAD_FAIL = 'ERROR_ESP_LOAD_FAIL' # Data not loaded
# Cache the information on the API location on the server
api_url = ''
def __init__(self, api_username='', api_password='', secure=True):
"""
Connect to the dotMailer API at apiconnector.com, using SUDS.
param string $ap_key Not present, because the dotMailer API doesn't support an API key
@param api_username Your dotMailer user name
@param api_password Your dotMailer password
@param secure Whether or not this should use a secure connection (HTTPS).
Always True if the ESP doesn't support an insecure API.
"""
# Remember the HTTPS flag
self.secure = secure or False # Cast to a boolean (?)
# Choose the dotMailer API URL
if secure:
self.api_url = 'https://apiconnector.com/API.asmx?WSDL'
else:
self.api_url = 'http://apiconnector.com/API.asmx?WSDL'
# Connect to the API, using SUDS. Log before and after to track the time taken.
logger.debug("Connecting to web service")
self.client = SOAPClient(self.api_url,
plugins=[DotMailerSudsPlugin()]) # Plugin makes a tiny XML patch for dotMailer
logger.debug("Connected to web service")
# Change the logging level to CRITICAL to avoid logging errors for every API call which fails via suds
logging.getLogger('suds.client').setLevel(logging.CRITICAL)
# Remember the username and password. There's no API key to remember with dotMailer
self.api_username = api_username
self.api_password = api_password
if (not api_username) or (not api_password):
raise Exception('Bad username or password')
self.last_exception = None
def unpack_exception(self, e):
""" unpack the exception thrown by suds. This contains a string code in e.fault.faultstring containing text e.g.
Server was unable to process request. ---> Campaign not found ERROR_CAMPAIGN_NOT_FOUND
Use this to set a suitable value for dict_result
@param e exception
@return dict_result, e.g. {'ok':False,
'errors':[e.message],
'error_code':PyDotMailer.ERRORS.ERROR_CAMPAIGN_NOT_FOUND }
"""
self.last_exception = e # in case caller cares
fault_string = ''
# http://stackoverflow.com/questions/610883/how-to-know-if-an-object-has-an-attribute-in-python
if e and hasattr(e, 'fault') and hasattr(e.fault, 'faultstring'):
fault_string = e.fault.faultstring
# todo clearly a more generic way of doing this would be good.
if 'ERROR_CAMPAIGN_NOT_FOUND' in fault_string:
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CAMPAIGN_NOT_FOUND
elif 'ERROR_CAMPAIGN_SENDNOTPERMITTED' in fault_string:
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CAMPAIGN_SENDNOTPERMITTED
elif 'ERROR_APIUSAGE_EXCEEDED' in fault_string:
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CAMPAIGN_APINOTPERMITTED
elif 'ERROR_CONTACT_NOT_FOUND' in fault_string:
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CONTACT_NOT_FOUND
elif 'ERROR_CONTACT_SUPPRESSED' in fault_string:
# Server was unable to process request. ---> Contact is suppressed. ERROR_CONTACT_SUPPRESSED
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CONTACT_UNSUBSCRIBED
else:
error_code = PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_OTHER
dict_result = {'ok': False, 'errors': [e.message], 'error_code': error_code}
return dict_result
def add_contacts_to_address_book(self, address_book_id, s_contacts, wait_to_complete_seconds=False):
"""
Add a list of contacts to the address book
@param address_book_id the id of the address book
@param s_contacts containing the contacts to be added. You may upload either a .csv or .xls file.
It must contain one column with the heading "Email".
Other columns must will attempt to map to your custom data fields.
@param wait_to_complete_seconds seconds to wait.
@return dict e.g. {'progress_id': 15edf1c4-ce5f-42e3-b182-3b20c880bcf8, 'ok': True, 'result': Finished}
http://www.dotmailer.co.uk/api/address_books/add_contacts_to_address_book_with_progress.aspx
"""
dict_result = {'ok': True}
return_code = None
base64_data = base64.b64encode(s_contacts)
try:
progress_id = self.client.service.AddContactsToAddressBookWithProgress(username=self.api_username,
password=self.api_password,
addressbookID=address_book_id,
data=base64_data,
dataType='CSV')
dict_result = {'ok': True}
if wait_to_complete_seconds:
# retry loop...
dt_wait_until = datetime.utcnow() + timedelta(seconds=wait_to_complete_seconds) # wait for max
sleep_time = 0.2 # start with short sleep between retries
while (not return_code or return_code.get('result') == 'NotFinished') and \
datetime.utcnow() < dt_wait_until:
time.sleep(sleep_time)
return_code = self.get_contact_import_progress(progress_id) # E.g: {'error_code': 'ERROR_UNFINISHED', 'ok': False, 'result': NotFinished}
# gradually backoff with longer sleep intervals up to a max of 5 seconds
sleep_time = min(sleep_time * 2, 5.0)
if return_code:
dict_result = return_code
dict_result.update({'progress_id': progress_id})
except Exception as e:
dict_result = self.unpack_exception(e)
return dict_result
def add_contact_to_address_book(self, address_book_id, email_address, d_fields, email_type="Html",
audience_type="Unknown",
opt_in_type="Unknown"):
"""
add a single contact into an address book. - uses AddContactToAddressBook
@param address_book_id the id of the address book
@param email_address The email address to add
@param d_fields - dict containing the data to be added. e.g. { 'firstname': 'mike', 'lastname': 'austin'}.
columns must map to standard fields in DM or will attempt to map to your custom data fields in DM.
@param email_type = "Html" - the new contact will be set to receive this format by default.
@return dict e.g. {'contact_id': 123532543, 'ok': True, 'contact': APIContact object }
"""
# Initialise the result dictionary
dict_result = {'ok': False}
# Create an APIContact object with the details of the record to load. For example:
# APIContact: (APIContact){
# ID = None, Email = None,
# AudienceType = (ContactAudienceTypes){ value = None, }
# DataFields = (ContactDataFields){ Keys = (ArrayOfString){ string[] = <empty> }
# Values = (ArrayOfAnyType){ anyType[] = <empty> }
# OptInType = (ContactOptInTypes){ value = None }
# EmailType = (ContactEmailTypes){ value = None }
# Notes = None }
contact = self.client.factory.create('APIContact')
del contact.ID
contact.Email = email_address
# Copy field data into the call
for field_name in d_fields:
if field_name != 'email' and d_fields.get(field_name):
contact.DataFields.Keys[0].append(field_name)
contact.DataFields.Values[0].append(d_fields.get(field_name))
# remove some empty values that will upset suds/dotMailer
####del contact.AudienceType
####del contact.OptInType
contact.AudienceType = audience_type
contact.OptInType = opt_in_type
contact.EmailType = email_type
#### logging.getLogger('suds.client').setLevel(logging.DEBUG)
try:
created_contact = self.client.service.AddContactToAddressBook(username=self.api_username,
password=self.api_password,
contact=contact,
addressbookId=address_book_id)
# Example dict_result contents:
# { 'contact': (APIContact){ ID = 417373614, Email = "test.mailings+unit_tests@triggeredmessaging.com",
# AudienceType = "Unknown",
# DataFields = (ContactDataFields){
# Keys = (ArrayOfString){ string[] = "Postcode", }
# Values = (ArrayOfAnyType){ anyType[] = "SW1A 0AA", } }
# OptInType = "Unknown", EmailType = "Html" },
# 'ok': True, 'contact_id': 417373614}
dict_result = ({'ok': True, 'contact_id': created_contact.ID, 'contact': created_contact})
except Exception as e:
dict_result = self.unpack_exception(e)
return dict_result
def get_contact_import_progress(self, progress_id):
"""
@param progress_id the progress_id from add_contacts_to_address_book
@return dict e.g. {'ok': False, 'result': NotFinished} or dict: {'ok': True, 'result': Finished}
http://www.dotmailer.co.uk/api/contacts/get_contact_import_progress.aspx
"""
dict_result = {'ok': True}
try:
return_code = self.client.service.GetContactImportProgress(username=self.api_username,
password=self.api_password,
progressID=progress_id)
if return_code == 'Finished':
dict_result = {'ok': True, 'result': return_code, 'errors': [' Load OK. See report at https://r1-app.dotmailer.com/Contacts/Import/WatchdogReport.aspx?g=%s ' % progress_id] }
elif return_code == 'RejectedByWatchdog':
# API call AddContactsToAddressBookWithProgress has triggered "RejectedByWatchdog" for one client and (we believe) dotMailer blocked the whole upload.
# https://support.dotmailer.com/entries/44346548-Data-Watchdog-FAQs
# https://support.dotmailer.com/entries/21449156-Better-API-feedback-for-Reject...
dict_result = {'ok': False, 'result': return_code, 'error_code':PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_ESP_LOAD_FAIL,
'errors': [' Load Fail. See report at https://r1-app.dotmailer.com/Contacts/Import/WatchdogReport.aspx?g=%s ' % progress_id]}
else:
dict_result = {'ok': False, 'result': return_code, 'error_code':PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_UNFINISHED,
'errors': [' Load Unfinished. See report at https://r1-app.dotmailer.com/Contacts/Import/WatchdogReport.aspx?g=%s ' % progress_id]}
except Exception as e:
dict_result = self.unpack_exception(e)
return dict_result # E.g: {'ok': True, 'result': Finished, 'errors': [u'<a href="https://r1-app.dotmailer.com/Contacts/Import/WatchdogReport.aspx?g=d82602bb-adfb-4e2d-aabc-5fb77af2ae3d">Load OK Report</a>']}
def send_campaign_to_contact(self, campaign_id, contact_id, send_date=None):
"""
@param campaign_id
@param contact_id
@param send_date date/time in server time when the campaign should be sent.
@return dict e.g. {'ok': True} or {'ok': False,
'result': <return code if there is one>,
'errors':['sample error']}
http://www.dotmailer.co.uk/api/campaigns/send_campaign_to_contact.aspx
"""
# format the date in ISO format, e.g. "2012-03-28T19:51:00" for sending via SOAP call.
if not send_date:
send_date = datetime.utcnow()
dict_result = {'ok': True}
iso_send_date = self.dt_to_iso_date(send_date)
return_code = None
try:
return_code = self.client.service.SendCampaignToContact(username=self.api_username,
password=self.api_password,
campaignId=campaign_id,
contactid=contact_id,
sendDate=iso_send_date) # note inconsistent case
# in DM API
if return_code:
# return code, which means an error
dict_result = {'ok': False, 'result': return_code}
except Exception as e:
dict_result = self.unpack_exception(e)
return dict_result
def get_contact_by_email(self, email):
"""
@param email email address to search for.
@return dict e.g. {'ok': True,
contact_id: 32323232, # the dotMailer contact ID
email: # the email address of the returned record
d_fields: { field_name: field_value }, # dictionary with multiple fields, keyed by field name
# The result member is the raw return from dotMailer.
'result': (APIContact){
ID = 367568124
Email = "test@blackhole.triggeredmessaging.com"
AudienceType = "Unknown"
DataFields =
(ContactDataFields){
Keys =
(ArrayOfString){
string[] =
"FIRSTNAME",
"FULLNAME",
"GENDER",
"LASTNAME",
"POSTCODE",
}
Values =
(ArrayOfAnyType){
anyType[] =
None,
None,
None,
None,
}
}
OptInType = "Unknown"
EmailType = "Html"
Notes = None
}}
http://www.dotmailer.co.uk/api/contacts/get_contact_by_email.aspx
"""
dict_result = {'ok': True}
data_fields = None
try:
return_code = self.client.service.GetContactByEmail(username=self.api_username,
password=self.api_password,
email=email)
dict_result = {'ok': True, 'result': return_code}
if dict_result.get('ok'):
# create a dictionary with structure { field_name: field_value }
try:
data_fields = dict_result.get('result').DataFields
d_fields = self._clean_returned_data_fields(data_fields=data_fields)
dict_result.update({'d_fields': d_fields})
except:
logger.exception("Exception unpacking fields in GetContactByEmail for email=%s" % email)
# log additional info separately in case something bad has happened
# which'll cause this logging line to raise.
logger.error("Further info: data_fields=%s" % data_fields)
contact_id = return_code.ID
dict_result.update({'contact_id': contact_id})
returned_email_address = return_code.Email
dict_result.update({'email': returned_email_address})
except Exception as e:
dict_result = self.unpack_exception(e)
error_code = dict_result.get("error_code")
if error_code == PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CONTACT_NOT_FOUND:
pass # ignore these expected errors
elif error_code == PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CAMPAIGN_APINOTPERMITTED:
pass
else:
logger.exception("Exception in GetContactByEmail")
return dict_result
def dt_to_iso_date(self, dt):
""" convert a python datetime to an iso date, e.g. "2012-03-28T19:51:00"
ready to send via SOAP
http://www.iso.org/iso/date_and_time_format
"""
try:
iso_dt = dt.strftime('%Y-%m-%dT%H:%M:%S')
except:
logger.exception('Exception converting dt to iso')
iso_dt = None
return iso_dt
def _clean_returned_data_fields(self, data_fields):
"""
Case 1886: If there's an empty first name/last name key, then dotMailer fails to return a value,
so the lengths don't match.
If this happens, scan through the keys and add an extra value of None just before the dodgy key(s)
len_data_fields_names = len(data_fields_keys)
len_data_fields_values = len(data_fields_values)
if len_data_fields_names > len_data_fields_values:
# Different number of keys and values, so do a copy but insert None when necessary
name_index = 0
value_index = 0
while name_index < len_data_fields_names:
field_name = data_fields_keys[name_index]
if name_index+1 < len_data_fields_names:
next_field_name = data_fields_keys[name_index+1]
else:
next_field_name = ""
if ((len_data_fields_names > len_data_fields_values)
and (next_field_name=="FIRSTNAME" or next_field_name=="LASTNAME" or next_field_name=="FULLNAME")):
d_fields.update({field_name: None }) # Insert new value Null
len_data_fields_values += 1 # Count one more value, but don't step on to next value
else:
d_fields.update({field_name: data_fields_values[value_index] }) # Copy the real value
value_index += 1 # Step on to next value
name_index += 1 # Next key
"""
d_fields = {}
data_fields_keys = data_fields.Keys[0]
data_fields_values = data_fields.Values[0]
# Case 1886: If there's an empty first name/last name key, then dotMailer fails to return a value,
# so the lengths don't match
# If this happens, scan through the keys and add an extra value of None just before the dodgy key(s)
len_data_fields_names = len(data_fields_keys)
len_data_fields_values = len(data_fields_values)
if len_data_fields_names > len_data_fields_values:
# Different number of keys and values, so do a copy but insert None when necessary
name_index = 0
value_index = 0
while name_index < len_data_fields_names:
field_name = data_fields_keys[name_index]
if name_index+1 < len_data_fields_names:
next_field_name = data_fields_keys[name_index+1]
else:
next_field_name = ""
if ((len_data_fields_names > len_data_fields_values)
and (next_field_name == "FIRSTNAME"
or next_field_name == "LASTNAME"
or next_field_name == "FULLNAME")):
d_fields.update({field_name: None}) # Insert new value Null
len_data_fields_values += 1 # Count one more value, but don't step on to next value
else:
d_fields.update({field_name: data_fields_values[value_index]}) # Copy the real value
value_index += 1 # Step on to next value
name_index += 1 # Next key
else:
# Same number of keys and values, so just do a straightforward copy
for idx, field_name in enumerate(data_fields_keys):
logger.debug(idx, field_name, data_fields_values[idx])
d_fields.update({field_name: data_fields_values[idx]})
return d_fields
def get_contact_by_id(self, contact_id):
"""
@param contact_id - id to search for
@return dict e.g. {'ok': True,
contact_id: 32323232, # the dotMailer contact ID
email: # the email address of the returned record
d_fields: { field_name: field_value }, # dictionary with multiple fields, keyed by field name
# The result member is the raw return from dotMailer.
'result': (APIContact){
ID = 367568124
Email = "test@blackhole.triggeredmessaging.com"
AudienceType = "Unknown"
DataFields =
(ContactDataFields){
Keys =
(ArrayOfString){
string[] =
"FIRSTNAME",
"FULLNAME",
"GENDER",
"LASTNAME",
"POSTCODE",
}
Values =
(ArrayOfAnyType){
anyType[] =
None,
None,
None,
None,
}
}
OptInType = "Unknown"
EmailType = "Html"
Notes = None
}}
http://www.dotmailer.co.uk/api/contacts/get_contact_by_id.aspx
"""
dict_result = {'ok': True}
data_fields = None
try:
return_code = self.client.service.GetContactById(username=self.api_username, password=self.api_password,
id=contact_id)
dict_result = {'ok': True, 'result': return_code}
if dict_result.get('ok'):
# create a dictionary with structure { field_name: field_value }
try:
d_fields = {}
data_fields = dict_result.get('result').DataFields
d_fields = self._clean_returned_data_fields(data_fields=data_fields)
dict_result.update({'d_fields': d_fields })
except:
logger.exception("Exception unpacking fields in GetContactById for id=%s" % contact_id)
# log additional info separately in case something bad has happened
# which'll cause this logging line to raise.
logger.error("Further info: data_fields=%s" % data_fields)
contact_id = return_code.ID
dict_result.update({'contact_id': contact_id})
returned_email_address = return_code.Email
dict_result.update({'email': returned_email_address})
except Exception as e:
dict_result = self.unpack_exception(e)
error_code = dict_result.get('error_code')
if error_code == PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CONTACT_NOT_FOUND:
pass # Don't log these expected errors
elif error_code == PyDotMailer.RESULT_FIELDS_ERROR_CODE.ERROR_CAMPAIGN_APINOTPERMITTED:
pass
return dict_result
"""
might implement a command line at some point.
def main():
try:
addressbookid = sys.argv[2] #should use argparse or similar.
contactsfilename = sys.argv[3]
except IndexError:
print "Usage: dotmailer addcontactstoaddressbook addressbookid contactsfilename\n"
sys.exit(1)
initial_data = open(contactsfilename, 'r').read()
"""