├── .gitignore ├── LICENCE ├── README.rst ├── config.py ├── pymailer.py ├── setup.py └── test.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.kpf 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Jonathan Bydendyk 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Jonathan Bydendyk nor the names of its contributors 12 | may be used to endorse or promote products derived from this software 13 | without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyMailer 2 | ======== 3 | **Simple python bulk mailer script. Raw python using std libs.** 4 | 5 | Send bulk html emails from the commandline or in your python script by specifying a database of recipients in csv form, a html template with var placeholders and a subject line. 6 | 7 | 8 | Requirements 9 | ------------ 10 | 11 | * python >= 2.4 12 | 13 | Usage 14 | ----- 15 | Setup 16 | ~~~~~ 17 | Edit the config file before running the script:: 18 | 19 | $ vim config.py 20 | 21 | Commandline 22 | ~~~~~~~~~~~ 23 | The simplest method of sending out a bulk email. 24 | 25 | Run a test to predefined test_recipients:: 26 | 27 | $ ./pymailer -t /path/to/html/file.html /path/to/csv/file.csv 'Email Subject' 28 | 29 | Send the actual email to all recipients:: 30 | 31 | $ ./pymailer -s /path/to/html/file.html /path/to/csv/file.csv 'Email Subject' 32 | 33 | Module Import 34 | ~~~~~~~~~~~~~ 35 | Alernatively import the PyMailer class into your own code:: 36 | 37 | from pymailer import PyMailer 38 | 39 | pymailer = PyMailer('/path/to/html/file.html', '/path/to/csv/file.csv', 'Email Subject') 40 | 41 | # send a test email 42 | pymailer.send_test() 43 | 44 | # send bulk mail 45 | pymailer.send() 46 | 47 | Examples 48 | -------- 49 | HTML 50 | ~~~~ 51 | Example of using placeholders in your html email:: 52 | 53 | 54 | 55 | 56 |

Test HTML Email -

57 |

Hi , This is a test email from Pymailer - http://github.com:80/qoda/PyMailer/.

58 | 59 | 60 | 61 | CSV 62 | ~~~ 63 | Example of how the csv file should look:: 64 | 65 | Someones Name,someone@example.com 66 | Someone Else,someone.else@example.com 67 | ,some.nameless.person@example.com 68 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global config file. Change variable below as needed but ensure that the log and 3 | retry files have the correct permissions. 4 | """ 5 | 6 | from datetime import datetime 7 | 8 | # file settings 9 | LOG_FILENAME = '/tmp/pymailer.log' 10 | CSV_RETRY_FILENAME = '/tmp/pymailer.csv' 11 | STATS_FILE = '/tmp/pymailer-%s.stat' % str(datetime.now()).replace(' ', '-').replace(':', '-').replace('.', '-') 12 | 13 | # smtp settings 14 | SMTP_HOST = 'localhost' 15 | SMTP_PORT = '25' 16 | 17 | # the address and name the email comes from 18 | FROM_NAME = 'Company Name' 19 | FROM_EMAIL = 'company@example.com' 20 | 21 | # test recipients list 22 | TEST_RECIPIENTS = [ 23 | {'name': 'Name', 'email': 'someone@example.com'}, 24 | ] 25 | -------------------------------------------------------------------------------- /pymailer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import csv 4 | from datetime import datetime 5 | from email import message 6 | import logging 7 | import os 8 | import re 9 | import smtplib 10 | import sys 11 | from time import sleep 12 | 13 | import config 14 | 15 | # Setup logging to specified log file 16 | logging.basicConfig(filename=config.LOG_FILENAME, level=logging.DEBUG) 17 | 18 | 19 | class PyMailer(): 20 | """ 21 | A python bulk mailer commandline utility. Takes five arguments: the path to the html file to be parsed; the 22 | database of recipients (.csv); the subject of the email; email adsress the mail comes from; and the name the email 23 | is from. 24 | """ 25 | def __init__(self, html_path, csv_path, subject, *args, **kwargs): 26 | self.html_path = html_path 27 | self.csv_path = csv_path 28 | self.subject = subject 29 | self.from_name = kwargs.get('from_name', config.FROM_NAME) 30 | self.from_email = kwargs.get('to_name', config.FROM_EMAIL) 31 | 32 | def _stats(self, message): 33 | """ 34 | Update stats log with: last recipient (incase the server crashes); datetime started; datetime ended; total 35 | number of recipients attempted; number of failed recipients; and database used. 36 | """ 37 | try: 38 | stats_file = open(config.STATS_FILE, 'r') 39 | except IOError: 40 | raise IOError("Invalid or missing stats file path.") 41 | 42 | stats_entries = stats_file.read().split('\n') 43 | 44 | # Check if the stats entry exists if it does overwrite it with the new message 45 | is_existing_entry = False 46 | if stats_entries: 47 | for i, entry in enumerate(stats_entries): 48 | if entry: 49 | if message[:5] == entry[:5]: 50 | stats_entries[i] = message 51 | is_existing_entry = True 52 | 53 | # If the entry does not exist append it to the file 54 | if not is_existing_entry: 55 | stats_entries.append(message) 56 | 57 | stats_file = open(config.STATS_FILE, 'w') 58 | for entry in stats_entries: 59 | if entry: 60 | stats_file.write("%s\n" % entry) 61 | stats_file.close() 62 | 63 | def _validate_email(self, email_address): 64 | """ 65 | Validate the supplied email address. 66 | """ 67 | if not email_address or len(email_address) < 5: 68 | return None 69 | if not re.match(r'^[a-zA-Z0-9._%-+]+@[a-zA-Z0-9._%-]+.[a-zA-Z]{2,6}$', email_address): 70 | return None 71 | return email_address 72 | 73 | def _retry_handler(self, recipient_data): 74 | """ 75 | Write failed recipient_data to csv file to be retried again later. 76 | """ 77 | try: 78 | csv_file = open(config.CSV_RETRY_FILENAME, 'wb+') 79 | except IOError: 80 | raise IOError("Invalid or missing csv file path.") 81 | csv_writer = csv.writer(csv_file) 82 | csv_writer.writerow([ 83 | recipient_data.get('name'), 84 | recipient_data.get('email') 85 | ]) 86 | csv_file.close() 87 | 88 | def _html_parser(self, recipient_data): 89 | """ 90 | Open, parse and substitute placeholders with recipient data. 91 | """ 92 | try: 93 | html_file = open(self.html_path, 'rb') 94 | except IOError: 95 | raise IOError("Invalid or missing html file path.") 96 | 97 | html_content = html_file.read() 98 | if not html_content: 99 | raise Exception("The html file is empty.") 100 | 101 | # Replace all placeolders associated to recipient_data keys 102 | if recipient_data: 103 | for key, value in recipient_data.items(): 104 | placeholder = "" % key 105 | html_content = html_content.replace(placeholder, value) 106 | 107 | return html_content 108 | 109 | def _form_email(self, recipient_data): 110 | """ 111 | Form the html email, including mimetype and headers. 112 | """ 113 | 114 | # Form the recipient and sender headers 115 | recipient = "%s <%s>" % (recipient_data.get('name'), recipient_data.get('email')) 116 | sender = "%s <%s>" % (self.from_name, self.from_email) 117 | 118 | # Get the html content 119 | html_content = self._html_parser(recipient_data) 120 | 121 | # Instatiate the email object and assign headers 122 | email_message = message.Message() 123 | email_message.add_header('From', sender) 124 | email_message.add_header('To', recipient) 125 | email_message.add_header('Subject', self.subject) 126 | email_message.add_header('MIME-Version', '1.0') 127 | email_message.add_header('Content-Type', 'text/html') 128 | email_message.set_payload(html_content) 129 | 130 | return email_message.as_string() 131 | 132 | def _parse_csv(self, csv_path=None): 133 | """ 134 | Parse the entire csv file and return a list of dicts. 135 | """ 136 | is_resend = csv_path is not None 137 | if not csv_path: 138 | csv_path = self.csv_path 139 | 140 | try: 141 | csv_file = open(csv_path, 'rwb') 142 | except IOError: 143 | raise IOError("Invalid or missing csv file path.") 144 | 145 | csv_reader = csv.reader(csv_file) 146 | recipient_data_list = [] 147 | for i, row in enumerate(csv_reader): 148 | # Test indexes exist and validate email address 149 | try: 150 | recipient_name = row[0] 151 | recipient_email = self._validate_email(row[1]) 152 | except IndexError: 153 | recipient_name = '' 154 | recipient_email = None 155 | 156 | print(recipient_name, recipient_email) 157 | 158 | # Log missing email addresses and line number 159 | if not recipient_email: 160 | logging.error("Recipient email missing in line %s" % i) 161 | else: 162 | recipient_data_list.append({ 163 | 'name': recipient_name, 164 | 'email': recipient_email, 165 | }) 166 | 167 | # Clear the contents of the resend csv file 168 | if is_resend: 169 | csv_file.write('') 170 | 171 | csv_file.close() 172 | 173 | return recipient_data_list 174 | 175 | def send(self, retry_count=0, recipient_list=None): 176 | """ 177 | Iterate over the recipient list and send the specified email. 178 | """ 179 | if not recipient_list: 180 | recipient_list = self._parse_csv() 181 | if retry_count: 182 | recipient_list = self._parse_csv(config.CSV_RETRY_FILENAME) 183 | 184 | # Save the number of recipient and time started to the stats file 185 | if not retry_count: 186 | self._stats("TOTAL RECIPIENTS: %s" % len(recipient_list)) 187 | self._stats("START TIME: %s" % datetime.now()) 188 | 189 | # Instantiate the number of falied recipients 190 | failed_recipients = 0 191 | 192 | for recipient_data in recipient_list: 193 | # Instantiate the required vars to send email 194 | message = self._form_email(recipient_data) 195 | if recipient_data.get('name'): 196 | recipient = "%s <%s>" % (recipient_data.get('name'), recipient_data.get('email')) 197 | else: 198 | recipient = recipient_data.get('email') 199 | sender = "%s <%s>" % (self.from_name, self.from_email) 200 | 201 | # Send the actual email 202 | smtp_server = smtplib.SMTP(host=config.SMTP_HOST, port=config.SMTP_PORT) 203 | try: 204 | smtp_server.sendmail(sender, recipient, message) 205 | 206 | # Save the last recipient to the stats file incase the process fails 207 | self._stats("LAST RECIPIENT: %s" % recipient) 208 | 209 | # Allow the system to sleep for .25 secs to take load off the SMTP server 210 | sleep(0.25) 211 | except: 212 | logging.error("Recipient email address failed: %s" % recipient) 213 | self._retry_handler(recipient_data) 214 | 215 | # Save the number of failed recipients to the stats file 216 | failed_recipients = failed_recipients + 1 217 | self._stats("FAILED RECIPIENTS: %s" % failed_recipients) 218 | 219 | def send_test(self): 220 | self.send(recipient_list=config.TEST_RECIPIENTS) 221 | 222 | def resend_failed(self): 223 | """ 224 | Try and resend to failed recipients two more times. 225 | """ 226 | for i in range(1, 3): 227 | self.send(retry_count=i) 228 | 229 | def count_recipients(self, csv_path=None): 230 | return len(self._parse_csv(csv_path)) 231 | 232 | 233 | def main(sys_args): 234 | if not os.path.exists(config.CSV_RETRY_FILENAME): 235 | open(config.CSV_RETRY_FILENAME, 'wb').close() 236 | 237 | if not os.path.exists(config.STATS_FILE): 238 | open(config.STATS_FILE, 'wb').close() 239 | 240 | try: 241 | action, html_path, csv_path, subject = sys_args 242 | except ValueError: 243 | print("Not enough argumants supplied. PyMailer requests 1 option and 3 arguments: ./pymailer -s html_path csv_path subject") 244 | sys.exit() 245 | 246 | if os.path.splitext(html_path)[1] != '.html': 247 | print("The html_path argument doesn't seem to contain a valid html file.") 248 | sys.exit() 249 | 250 | if os.path.splitext(csv_path)[1] != '.csv': 251 | print("The csv_path argument doesn't seem to contain a valid csv file.") 252 | sys.exit() 253 | 254 | pymailer = PyMailer(html_path, csv_path, subject) 255 | 256 | if action == '-s': 257 | confirmation = raw_input( 258 | "You are about to send to {} recipients. Do you want to continue (yes/no)? ".format(pymailer.count_recipients()) 259 | ) 260 | if confirmation in ['yes', 'y']: 261 | 262 | # Save the csv file used to the stats file 263 | pymailer._stats("CSV USED: %s" % csv_path) 264 | 265 | # Send the email and try resend to failed recipients 266 | pymailer.send() 267 | pymailer.resend_failed() 268 | else: 269 | print("Aborted.") 270 | sys.exit() 271 | 272 | elif action == '-t': 273 | confirmation = raw_input( 274 | "You are about to send a test mail to all recipients as specified in config.py. Do you want to continue (yes/no)? " 275 | ) 276 | if confirmation in ['yes', 'y']: 277 | pymailer.send_test() 278 | else: 279 | print("Aborted.") 280 | sys.exit() 281 | 282 | else: 283 | print("{} option is not support. Use either [-s] to send to all recipients or [-t] to send to test recipients".format(action)) 284 | 285 | # Save the end time to the stats file 286 | pymailer._stats("END TIME: %s" % datetime.now()) 287 | 288 | if __name__ == '__main__': 289 | main(sys.argv[1:]) 290 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='pymailer', 5 | version='0.1.0', 6 | description='Simple python bulk mailer script.', 7 | author='Jonathan Bydendyk', 8 | author_email='jonathan@blu-marble.co.za', 9 | url='http://github.com/qoda/PyMailer', 10 | packages=find_packages(), 11 | include_package_data=True, 12 | ) 13 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Test HTML Email -

5 |

Hi , This is a test email from Pymailer - http://github.com:80/qoda/PyMailer/.

6 | 7 | 8 | --------------------------------------------------------------------------------