├── .gitignore ├── CHANGELOG.txt ├── LICENSE.txt ├── MANIFEST ├── README.md ├── setup.cfg ├── setup.py ├── strudelpy ├── __init__.py └── tests │ ├── __init__.py │ ├── cat.jpg │ ├── doctest.doc │ ├── grumpy-cat.gif │ └── tests.py └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | .idea 3 | *.pyc 4 | *.swp 5 | __pycache__ 6 | dist 7 | .vscode 8 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | StrudelPy Change Log 2 | 3 | 0.4.1 4 | ----------- 5 | Fix Content-ID to use the right cid value (minus file extension) 6 | Add X-Attachment-Id header for embedded images 7 | 8 | 0.4.0 9 | ----------- 10 | Fix CC and BCC not being sent correctly 11 | 12 | 0.3.9 13 | ----------- 14 | Resolve issues when using the Email class to send with attachments via Gmail API. 15 | 16 | 0.3.8 17 | ----------- 18 | Better default handling of TLS. 19 | TLS version can be set on input along side a context handler to configure the SSLContext. 20 | 21 | 0.3.7 22 | ----------- 23 | Bug fix - sent str instead of bytes 24 | 25 | 0.3.6 26 | ----------- 27 | Configurable TLS version 28 | 29 | 0.3.4 30 | ----------- 31 | * Fix embedded image double encoding and add content-disposition header 32 | 33 | 0.3.3 34 | ----------- 35 | * Different strategy dealing with connect 36 | 37 | 0.3.2 38 | ----------- 39 | * Always call connect, even when using smtp auth 40 | 41 | 0.3 42 | ----------- 43 | * Added Python 3.x support 44 | * Update timeout test to use a non routable ip 45 | 46 | 0.2 47 | ----------- 48 | * Moved get_client call of SMTP account to the login() method. Makes loging in more lazy. 49 | * Updates to README.md 50 | 51 | 0.1 52 | ----------- 53 | * Initial Release -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Harel Malka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.cfg 3 | setup.py 4 | strudelpy/__init__.py 5 | strudelpy/tests/__init__.py 6 | strudelpy/tests/tests.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## StrudelPy v0.4.1 2 | ### A tastier way to send emails with Python 3 | 4 | ### Features 5 | * Attachments, multiple recipients, cc, bcc - the standard stuff 6 | * Embedded Images 7 | * Plays well with Unicode 8 | * Easy OOP approach 9 | * Supports Python 2 and 3 (with six) 10 | 11 | 12 | ### Setup 13 | 14 | ``` 15 | pip install strudelpy 16 | ``` 17 | 18 | ### TL;DR 19 | 20 | ``` 21 | from strudelpy import SMTP, Email 22 | 23 | smtpclient = SMTP('smtp.example.com', 456, 'myuser', 'muchsecret', ssl=True) 24 | with smtpclient as smtp: 25 | smtp.send( 26 | Email( 27 | sender='me@example.com, 28 | recipients=['him@example.com', 'her@example.com'], 29 | subject='The Subject Matters', 30 | text='Plain text body', 31 | html='HTML body' 32 | ) 33 | ) 34 | ``` 35 | 36 | ### The 'Can Read' Version 37 | 38 | Strudelpy consists mainly of two objects: `SMTP` to manage connections to SMTP 39 | servers, and Email to encapsulate an email message. 40 | 41 | 42 | #### SMTP 43 | 44 | ``` 45 | SMTP(host='some.host.com', 46 | port=465, 47 | username='username', 48 | password='password', 49 | ssl=True, 50 | tls=False, 51 | timeout=None, 52 | debug_level=None 53 | ) 54 | ``` 55 | 56 | Unless using SMTP objects with `with`, you'll need to `login()` and `close()` the connection. 57 | 58 | 59 | #### Email 60 | 61 | You can then send emails using the `Email` object: 62 | ``` 63 | email = Email(sender='me@example.com, 64 | recipients=['him@example.com', 'her@example.com'], 65 | cc=['cc@me.com'], 66 | bcc=['shh@dontell.com'], 67 | subject='The Subject Matters', 68 | text='Plain text body', 69 | html='HTML body', 70 | attachments=['absolute/path/to/file'], 71 | embedded=['absolute/path/to/image/']) 72 | smtp.send(email) 73 | ``` 74 | 75 | Emails can use embedded images by including tags like this in the html content: 76 | 77 | ``` 78 | 79 | ``` 80 | 81 | Look at the tests/tests.py file for examples. 82 | 83 | 84 | #### Email & Gmail etc. 85 | 86 | The Email class can be used to construct emails to be delivered via the Gmail (or other) 87 | api: 88 | 89 | ``` 90 | email = Email( 91 | sender="me@example.com", 92 | recipients=['them@example.com'], 93 | subject='The Subject Matters', 94 | html='Dear so and so', 95 | attachments=["/path/to/file.png"] 96 | ) 97 | _message = {'raw': base64.urlsafe_b64encode( 98 | bytes(email.get_payload(), 'utf-8') 99 | ).decode('utf-8')} 100 | client = get_gmail_client_thingy() 101 | client.users().messages().send(userId='me', body=_message).execute() 102 | ``` 103 | 104 | 105 | #### TLS 106 | (v0.3.8): It's possible to pass a specific TLS version (ssl.PROTOCOL_TLS*) to the SMTP 107 | init, as well as function to work on the context if required (like setting min/max TLS versions, cert location etc.). By default the default context is generated. 108 | 109 | `tls_version`: e.g. ssl.PROTOCOL_TLSv1_2 110 | `tls_context_handler`: Any function that receives SSLContext instance as first argument. 111 | 112 | #### Tests 113 | 114 | This test suite relies on the existence of a SMTP server, real or fake to connect to. 115 | By default it will attempt to connect to a 'fake' one that can be run using: 116 | 117 | `sudo python -m smtpd -n -c DebuggingServer localhost:25` 118 | 119 | Set TEST_CONFIG_NAME to one of the keys in TEST_CONFIGURATIONS to test a specific configuration 120 | 121 | 122 | #### Still to do 123 | 124 | * Fix tests to use a fake smtp server by default 125 | * Your issues??? 126 | 127 | ##### Motivation and Similar Projects 128 | StrudelPy was created because I needed just that kind of functionality, in that exact way. I had an itch and my searches for a scratcher did not produce anything suitable. I took some inspiration from an older similar project of mine from way back in 2007, and [Pyzmail](http://www.magiksys.net/pyzmail/) which was nice but not as simple as I'd like to have had it. However after releasing I've found out about [mailthon](https://github.com/eugene-eeo/mailthon) which shares the same motives and motivation behind StrudelPy. I might have not done this had I known about Mialthon. 129 | 130 | And by the way, StrudelPy is named after the `at` sign: @, which in Israel is called "The Strudel"... 131 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='strudelpy', 5 | version='0.4.1', 6 | description='Easy as Pie Emails in Python', 7 | long_description='StrudelPy is an easy to use library to manage sending emails in a OO way.' 8 | 'The library is comprised of a SMTP object to manage connections to SMTP' 9 | 'servers and an Email object to handle the messages themselves.' 10 | 'Supports Python 2 and 3.', 11 | author='Harel Malka', 12 | author_email='harel@harelmalka.com', 13 | url='https://github.com/harel/strudelpy', 14 | download_url='https://github.com/harel/strudelpy/archive/0.3.tar.gz', 15 | keywords=['email', 'smtp'], # arbitrary keywords 16 | install_requires=['six'], 17 | license='MIT', 18 | packages=['strudelpy', 'strudelpy.tests'], 19 | classifiers=[ 20 | 'Development Status :: 4 - Beta', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Intended Audience :: Developers', 23 | 'Topic :: Communications :: Email', 24 | 'Programming Language :: Python :: 2.7', 25 | 'Programming Language :: Python :: 3.5', 26 | ] 27 | ) -------------------------------------------------------------------------------- /strudelpy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | An human friendlier way to send emails with Python 3 | 4 | smtp = SMTP(host='smtp.example.com', port=21, username='myuser', password='suchsecret') 5 | email = Email(sender='harel@harelmalka.com', recipients=['harel@harelmalka.com'], 6 | subject='Riches to you!', text='You won the Microsoft Lottery!') 7 | email.send(using=smtp) 8 | 9 | or 10 | 11 | smtp.send(email) 12 | 13 | """ 14 | 15 | import os 16 | import six 17 | import base64 18 | import uuid 19 | import ssl 20 | import smtplib 21 | import mimetypes 22 | from email.mime.text import MIMEText 23 | from email.mime.image import MIMEImage 24 | from email.mime.base import MIMEBase 25 | from email.mime.multipart import MIMEMultipart 26 | from email.header import Header 27 | from email.utils import formatdate, formataddr, make_msgid 28 | from email.encoders import encode_base64 29 | from email.charset import Charset 30 | 31 | __author__ = 'Harel Malka' 32 | __version__ = '0.4.1' 33 | 34 | # initialise the mimetypes module 35 | mimetypes.init() 36 | 37 | try: 38 | PROTOCOL_TLS = getattr(ssl, os.environ.get('EMAIL_TLS_VERSION', 'PROTOCOL_TLS')) 39 | except AttributeError: 40 | PROTOCOL_TLS = getattr(ssl, 'PROTOCOL_TLS') 41 | 42 | 43 | class InvalidConfiguration(Exception): 44 | pass 45 | 46 | 47 | class SMTP(object): 48 | """ 49 | A wrapper around SMTP accounts. 50 | This object can be used as a stand alone SMTP wrapper which can accept Email objects to be sent 51 | out through it (the opposite is also valid - en Email object can receive this SMTP object to 52 | use as a transport). 53 | However, a better usage pattern is via the `with` keyword: 54 | 55 | with smtp_instance as smtp: 56 | Email(...).send() 57 | 58 | """ 59 | def __init__( 60 | self, host, port, username=None, password=None, ssl=False, tls=False, 61 | timeout=None, debug_level=None, tls_version=None, tls_context_handler=None 62 | ): 63 | self.host = host 64 | self.port = port 65 | self.username = username 66 | self.password = password 67 | self.ssl = ssl 68 | self.tls = tls 69 | self.tls_version = tls_version 70 | self.tls_context_handler = tls_context_handler 71 | self.timeout = timeout 72 | self.debug_level = debug_level 73 | self.client = None 74 | 75 | def __enter__(self): 76 | self.login() 77 | return self 78 | 79 | def __exit__(self, exc_type, exc_val, exc_tb): 80 | self.close() 81 | 82 | def _get_client(self): 83 | """ 84 | Returns the relevant SMTP client (SMTP or SMTP_SSL) 85 | """ 86 | connection_args = { 87 | 'host': self.host, 88 | 'port': self.port, 89 | } 90 | if self.timeout: 91 | connection_args['timeout'] = self.timeout 92 | if self.ssl: 93 | client = smtplib.SMTP_SSL(**connection_args) 94 | else: 95 | client = smtplib.SMTP(**connection_args) 96 | if self.tls: 97 | if self.tls_version: 98 | context = ssl.SSLContext(self.tls_version) 99 | if self.tls_context_handler: 100 | self.tls_context_handler(context) 101 | else: 102 | context = ssl.create_default_context() 103 | client.starttls(context=context) 104 | client.ehlo_or_helo_if_needed() 105 | if self.debug_level: 106 | client.set_debuglevel(self.debug_level) 107 | return client 108 | 109 | def login(self): 110 | """ 111 | Connect to the server using the login (with credentials) or connect (without). 112 | If login() fails, attempt to perform a fallback method using base64 encoded password and 113 | raw SMTP commands 114 | """ 115 | self.client = self._get_client() 116 | if self.username and self.password: 117 | try: 118 | self.client.login(six.u(self.username), six.u(self.password)) 119 | except (smtplib.SMTPException, smtplib.SMTPAuthenticationError): 120 | # if login fails, try again using a manual plain login method 121 | self.client.connect(host=self.host, port=self.port) 122 | self.client.docmd("AUTH LOGIN", base64.b64encode(six.b(self.username))) 123 | self.client.docmd(base64.b64encode(six.b(self.password)), six.b("")) 124 | else: 125 | self.client.connect() 126 | 127 | def close(self): 128 | self.client.quit() 129 | 130 | def send(self, email): 131 | """ 132 | Send an Email 133 | """ 134 | return self.client.sendmail(email.sender, email.recipients, email.get_payload()) 135 | 136 | 137 | class Email(object): 138 | """ 139 | A fully composed email message. 140 | The email can contain plain text and/or html, multiple recipients, cc and bcc. 141 | Attachments can be added as well as embedded images 142 | """ 143 | def __init__(self, sender=None, recipients=[], cc=[], bcc=[], 144 | subject=None, text=None, html=None, charset=None, 145 | attachments=[], embedded=[], headers=[]): 146 | self.sender = sender 147 | self.recipients = recipients if type(recipients) in (list, tuple) else [recipients] 148 | self.cc = cc 149 | self.bcc = bcc 150 | self.subject = subject 151 | self.text = text 152 | self.html = html 153 | self.attachments = attachments 154 | self.embedded = embedded 155 | self.charset = charset or 'utf-8' 156 | self.headers = headers 157 | self.compiled = False 158 | 159 | def compile_message(self): 160 | """ 161 | Compile this message with all its parts 162 | :return: the compiled Message object 163 | """ 164 | message = self.get_root_message() 165 | if self.attachments: 166 | # add the attachments as parts of the Multi part message 167 | for attachment in self.attachments: 168 | message.attach(self.get_file_attachment(attachment)) 169 | if self.embedded: 170 | for embedded in self.embedded: 171 | message.attach(self.get_embedded_image(embedded)) 172 | self.message = message 173 | self.compiled = True 174 | return self.message 175 | 176 | def get_payload(self): 177 | """ 178 | Return the final payload of this email. Its compiled if not previously done so. 179 | :return: payload as string 180 | """ 181 | if not self.compiled: 182 | self.compile_message() 183 | return self.message.as_string() 184 | 185 | def is_valid_message(self): 186 | """ 187 | Validate all the required properties of the email are present and raise an 188 | InvalidConfiguration exception if some are missing. 189 | :return: True is the message is valid 190 | """ 191 | if not self.sender: 192 | raise InvalidConfiguration('Sender is required') 193 | if not self.recipients and not self.cc and not self.bcc: 194 | raise InvalidConfiguration('No recipients provided') 195 | return True 196 | 197 | def get_root_message(self): 198 | """ 199 | Return the top level Message object which can be a standard Mime message or a 200 | Multi Part email. All the initial fields are set on the message. 201 | :return: email.Message object 202 | """ 203 | 204 | if (self.text and self.html) or self.attachments or self.embedded: 205 | self.message = MIMEMultipart('mixed') 206 | message_alt = MIMEMultipart('alternative', None) 207 | message_rel = MIMEMultipart('related') 208 | if self.text: 209 | message_alt.attach(self.get_email_part(self.text, 'plain')) 210 | elif self.html: 211 | message_alt.attach(self.get_email_part(self.html, 'html')) 212 | if self.html: 213 | message_rel.attach(self.get_email_part(self.html, 'html')) 214 | message_alt.attach(message_rel) 215 | self.message.attach(message_alt) 216 | elif self.text or self.html: 217 | if self.text: 218 | self.message = MIMEText(self.text.encode(self.charset), 'plain', self.charset) 219 | else: 220 | self.message = MIMEText(self.html.encode(self.charset), 'html', self.charset) 221 | else: 222 | self.message = MIMEText('', 'plain', 'us-ascii') 223 | self.message['From'] = self.format_email_address(email_type='from', emails=[self.sender]) 224 | if self.recipients: 225 | self.message['To'] = self.format_email_address(email_type='to', emails=self.recipients) 226 | 227 | if self.cc: 228 | self.message['Cc'] = self.format_email_address(email_type='cc', emails=self.cc) 229 | if self.bcc: 230 | self.message['Bcc'] = self.format_email_address(email_type='bcc', emails=self.bcc) 231 | 232 | self.message['Subject'] = self.get_header('subject', self.subject) 233 | self.message['Date'] = formatdate(localtime=True) # TODO check formatdate 234 | self.message['Message-ID'] = make_msgid(str(uuid.uuid4())) 235 | self.message['X-Mailer'] = 'Strudelpy Python Client' 236 | return self.message 237 | 238 | def get_header(self, name, value=None): 239 | """ 240 | Return a single email header 241 | :param name: the header name 242 | :return: Header instance 243 | """ 244 | _header = Header(header_name=name, charset=self.charset) 245 | if value: 246 | _header.append(value) 247 | return _header 248 | 249 | def format_email_address(self, email_type, emails=None): 250 | """ 251 | returns email headers with email information. 252 | :param email_type: One of: from|to|cc|bcc 253 | :param emails: A list of email address or list/tuple of (name, email) pairs. 254 | :return: the email header 255 | """ 256 | emails = emails or self.recipients or [] 257 | header = self.get_header(email_type) 258 | for i, address in enumerate(emails): 259 | if i > 0: # ensure emails are separated by commas 260 | header.append(',', 'us-ascii') 261 | if isinstance(address, six.string_types): 262 | # address is a string. use as is. Attempt it as ascii first and 263 | # if fails, send in the default charset 264 | try: 265 | header.append(address, charset='us-ascii') 266 | except UnicodeError: 267 | header.append(address, charset=self.charset) 268 | elif type(address) in (tuple, list): 269 | # address is a list or tuple (name, email): format it 270 | _name, _address = address 271 | try: 272 | _name.encode('us-ascii') 273 | formatted_address = formataddr(address) 274 | header.append(formatted_address, charset='us-ascii') 275 | except UnicodeError: # this is not an ascii name - append it separately 276 | header.append(_name) 277 | header.append('<{0}>'.format(_address), charset='us-ascii') 278 | return header 279 | 280 | def get_embedded_image(self, path): 281 | email_part = self.get_file_mimetype(path) 282 | encode_base64(email_part) 283 | path_basename = os.path.basename(path) 284 | cid_value = path_basename.split('.')[0] 285 | email_part.add_header('Content-ID', '<{0}>'.format(cid_value)) 286 | email_part.add_header('X-Attachment-Id', '<{0}>'.format(cid_value)) 287 | email_part.add_header('Content-Disposition', 'attachment; filename="%s"' % path_basename) 288 | return email_part 289 | 290 | def get_file_attachment(self, path): 291 | """ 292 | Return a MIMEBase email part with the file under path as payload. 293 | If the file is not textual, it is encoded as base64 294 | :param path: Absolute path to the file being attached 295 | :return: MIMEBase object 296 | """ 297 | # todo test graceful failure of this 298 | email_part = self.get_file_mimetype(path) 299 | if not email_part.get_payload(): 300 | with open(path, 'rb') as attached_file: 301 | email_part.set_payload(attached_file.read()) 302 | # no need to base64 plain text 303 | if email_part.get_content_maintype() != "text": 304 | encode_base64(email_part) 305 | email_part.add_header('Content-Disposition', 'attachment; filename="%s"' % os.path.basename(path)) 306 | return email_part 307 | 308 | def get_file_mimetype(self, path, fallback=None): 309 | """ 310 | Try to assert the mime type of this file. 311 | If the mime type cannot be guessed, the fallback is used (default to text/plain) 312 | :param path: The absolute path to the file 313 | :param fallback: Fallback mime type as a 2 item array, e.g. ["application", "octet-stream"] 314 | :return: MIMEBase object with the mime type 315 | """ 316 | # todo look into guess_type 317 | fallback = fallback or ['text', 'plain'] 318 | asserted_mimetype = mimetypes.guess_type(path, False)[0] 319 | if asserted_mimetype is None: 320 | mimetype = MIMEBase(*fallback) 321 | elif asserted_mimetype.startswith('image'): 322 | with open(path, 'rb') as embedded_file: 323 | mimetype = MIMEImage(embedded_file.read(), _subtype=asserted_mimetype.split('/')[1]) 324 | else: 325 | mimetype = MIMEBase(*asserted_mimetype.split('/')) 326 | return mimetype 327 | 328 | def get_email_part(self, body, format='html'): 329 | """ 330 | Return a single MIMEText email part to be used in multipart messages containing both 331 | plain text and html bodies. 332 | :param body: the body text 333 | :param format: html or plain 334 | :return:MIMEText instance 335 | """ 336 | charset = Charset(self.charset) 337 | email_part = MIMEText(body, format, self.charset) 338 | email_part.set_charset(charset) 339 | return email_part 340 | 341 | def add_header(self, header): 342 | """ 343 | Add a custom header to this email 344 | :param header: header text of Header instance 345 | """ 346 | if isinstance(header, six.string_types): 347 | header = Header(header) 348 | self.headers.append(header) 349 | 350 | def add_recipient(self, recipient): 351 | """ 352 | Add an additional recipient to this email 353 | :param recipient: An email address or a name/email tuple 354 | """ 355 | self.recipients.append(recipient) 356 | 357 | def add_attachment(self, file): 358 | """ 359 | Add an attachment to this email 360 | :param file: absolute path to file 361 | """ 362 | self.attachments.append(file) 363 | 364 | def add_embedded_image(self, image): 365 | """ 366 | Adds an embedded image path to the embedded list 367 | :param image: string path to the image 368 | """ 369 | self.embedded.append(image) 370 | -------------------------------------------------------------------------------- /strudelpy/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harel/strudelpy/a920b8ff7399b5c64e9b6901f167942962d53dd5/strudelpy/tests/__init__.py -------------------------------------------------------------------------------- /strudelpy/tests/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harel/strudelpy/a920b8ff7399b5c64e9b6901f167942962d53dd5/strudelpy/tests/cat.jpg -------------------------------------------------------------------------------- /strudelpy/tests/doctest.doc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harel/strudelpy/a920b8ff7399b5c64e9b6901f167942962d53dd5/strudelpy/tests/doctest.doc -------------------------------------------------------------------------------- /strudelpy/tests/grumpy-cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harel/strudelpy/a920b8ff7399b5c64e9b6901f167942962d53dd5/strudelpy/tests/grumpy-cat.gif -------------------------------------------------------------------------------- /strudelpy/tests/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | This test suite relies on the existence of a SMTP server, real or fake to connect to. 6 | By default it will attempt to connect to a 'fake' one that can be run using: 7 | 8 | `sudo python -m smtpd -n -c DebuggingServer localhost:25` 9 | 10 | Set TEST_CONFIG_NAME to one of the keys in TEST_CONFIGURATIONS to test a specific configuration 11 | """ 12 | import os 13 | import smtplib 14 | import socket 15 | import unittest 16 | from strudelpy import Email, SMTP 17 | from strudelpy import InvalidConfiguration 18 | 19 | TEST_CONFIG_NAME = 'fake' 20 | 21 | TEST_CONFIGURATIONS = { 22 | 'gmail': { 23 | 'SMTP_HOST': 'smtp.gmail.com', 24 | 'SMTP_PORT': 465, 25 | 'SSL': True, 26 | 'TLS': False, 27 | 'SMTP_USER': 'mygmail@gmail.com', 28 | 'SMTP_PASS': 'gmailpass', 29 | 'FROM': 'mygmail@gmail.com', 30 | 'RECIPIENTS': ['someone@example.com', 'anotherone@example.com'], 31 | 'RECIPIENT_PAIRS': (('Harel Malka', 'someone@example.com'), ('Donkey Kong', 'donkey@example.com')) 32 | }, 33 | 'mailtrap': { 34 | 'SMTP_HOST': 'mailtrap.io', 35 | 'SMTP_PORT': 465, 36 | 'SSL': False, 37 | 'TLS': True, 38 | 'SMTP_USER': '123455', 39 | 'SMTP_PASS': '432123', 40 | 'FROM': 'harel@example.com', 41 | 'RECIPIENTS': ['someone@example.com', 'anotherone@example.com'], 42 | 'RECIPIENT_PAIRS': (('Harel Malka', 'someone@example.com'), ('Donkey Kong', 'donkey@example.com')) 43 | }, 44 | 'fake': { 45 | 'SMTP_HOST': 'localhost', 46 | 'SMTP_PORT': 25, 47 | 'SSL': False, 48 | 'TLS': False, 49 | 'SMTP_USER': 'user', 50 | 'SMTP_PASS': 'pass', 51 | 'FROM': 'harel@harelmalka.com', 52 | 'RECIPIENTS': ['harel@harelmalka.com'], 53 | 'RECIPIENT_PAIRS': (('Harel Malka', 'someone@example.com'), ('Mario Plumber', 'mario@example.com')) 54 | } 55 | } 56 | 57 | 58 | TEST_CONFIG = TEST_CONFIGURATIONS[TEST_CONFIG_NAME] 59 | 60 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 61 | 62 | 63 | class TestEmailSend(unittest.TestCase): 64 | def setUp(self): 65 | self.smtp = SMTP(host=TEST_CONFIG['SMTP_HOST'], port=TEST_CONFIG['SMTP_PORT'], 66 | username=TEST_CONFIG['SMTP_USER'], password=TEST_CONFIG['SMTP_PASS'], 67 | ssl=TEST_CONFIG['SSL'], tls=TEST_CONFIG['TLS']) 68 | 69 | def test_is_valid_message_no_recipient(self): 70 | """ 71 | Test email validation fails when no recipient is provided 72 | """ 73 | email = Email(sender=TEST_CONFIG['FROM'], 74 | subject='Test: test_is_valid_message_no_recipient', 75 | text='Simple text only body') 76 | no_recipient = False 77 | try: 78 | email.is_valid_message() 79 | except InvalidConfiguration: 80 | no_recipient = True 81 | self.assertEqual(no_recipient, True) 82 | 83 | def test_is_valid_message_no_sender(self): 84 | """ 85 | Test email validation fails when no sender is provided 86 | """ 87 | email = Email(recipients=TEST_CONFIG['RECIPIENTS'], 88 | subject='Test: test_is_valid_message_no_sender', 89 | text='Simple text only body') 90 | no_sender = False 91 | try: 92 | email.is_valid_message() 93 | except InvalidConfiguration: 94 | no_sender = True 95 | self.assertEqual(no_sender, True) 96 | 97 | def test_format_email_address(self): 98 | email = Email(sender=TEST_CONFIG['FROM'], recipients=TEST_CONFIG['RECIPIENTS'], subject="Subject") 99 | header = email.format_email_address('from', TEST_CONFIG['RECIPIENTS']) 100 | self.assertEqual(str(header), "harel@harelmalka.com") 101 | 102 | 103 | def test_simple_text_only_single_recipient(self): 104 | with self.smtp as smtp: 105 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 106 | recipients=TEST_CONFIG['RECIPIENTS'][0], 107 | subject='Test: test_simple_text_only_single_recipient', 108 | text='Simple text only body')) 109 | self.assertEqual(response, {}) 110 | 111 | def test_simple_text_only_multiple_recipient(self): 112 | with self.smtp as smtp: 113 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 114 | recipients=TEST_CONFIG['RECIPIENTS'], 115 | subject='Test: test_simple_text_only_multiple_recipient', 116 | text='Simple text only body')) 117 | self.assertEqual(response, {}) 118 | 119 | def test_simple_text_only_multiple_recipient_pairs(self): 120 | with self.smtp as smtp: 121 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 122 | recipients=TEST_CONFIG['RECIPIENT_PAIRS'], 123 | subject='Test: test_simple_text_only_multiple_recipient_pairs', 124 | text='Simple text only body')) 125 | self.assertEqual(response, {}) 126 | 127 | def test_text_html_multiple_recipient(self): 128 | with self.smtp as smtp: 129 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 130 | recipients=TEST_CONFIG['RECIPIENTS'], 131 | subject='Test: test_text_html_multiple_recipient', 132 | text='Simple text only body', 133 | html='Complicated
HTML')) 134 | self.assertEqual(response, {}) 135 | 136 | def test_unicode_data(self): 137 | with self.smtp as smtp: 138 | response = smtp.send(Email(sender="הראל מלכה ", 139 | recipients=TEST_CONFIG['RECIPIENTS'], 140 | subject='Test: עברית test_unicode_data', 141 | text='Simple text only body בעברית', 142 | html='Complicated עברית
HTML')) 143 | self.assertEqual(response, {}) 144 | 145 | def test_single_file_attachment(self): 146 | with self.smtp as smtp: 147 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 148 | recipients=TEST_CONFIG['RECIPIENTS'], 149 | subject='Test: test_single_file_attachment', 150 | text='Simple text only body', 151 | attachments=[os.path.join(BASE_DIR, 'tests', 'doctest.doc')])) 152 | self.assertEqual(response, {}) 153 | 154 | def test_multiple_file_attachment(self): 155 | with self.smtp as smtp: 156 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 157 | recipients=TEST_CONFIG['RECIPIENTS'], 158 | subject='Test: test_single_file_attachment', 159 | text='Simple text only body', 160 | attachments=[os.path.join(BASE_DIR, 'tests', 'doctest.doc'), 161 | os.path.join(BASE_DIR, 'tests', 'cat.jpg')])) 162 | 163 | def test_embedded_image(self): 164 | with self.smtp as smtp: 165 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 166 | recipients=TEST_CONFIG['RECIPIENTS'], 167 | subject='Test: test_embedded_image', 168 | html='Here is a cat

', 169 | embedded=[os.path.join(BASE_DIR, 'tests', 'cat.jpg')])) 170 | self.assertEqual(response, {}) 171 | 172 | def test_multiple_embedded_images(self): 173 | with self.smtp as smtp: 174 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 175 | recipients=TEST_CONFIG['RECIPIENTS'], 176 | subject='Test: test_multiple_embedded_images', 177 | html='''Here is a cat

178 |

179 |
And another: 180 | 181 | ''', 182 | embedded=[os.path.join(BASE_DIR, 'tests', 'cat.jpg'), 183 | os.path.join(BASE_DIR, 'tests', 'grumpy-cat.gif')])) 184 | self.assertEqual(response, {}) 185 | 186 | def test_simple_text_only_single_recipient_debug_level(self): 187 | smtp = SMTP(host=TEST_CONFIG['SMTP_HOST'], port=TEST_CONFIG['SMTP_PORT'], 188 | username=TEST_CONFIG['SMTP_USER'], password=TEST_CONFIG['SMTP_PASS'], 189 | ssl=TEST_CONFIG['SSL'], tls=TEST_CONFIG['TLS'], debug_level=5) 190 | smtp.login() 191 | response = smtp.send(Email(sender=TEST_CONFIG['FROM'], 192 | recipients=TEST_CONFIG['RECIPIENTS'][0], 193 | subject='Test: test_simple_text_only_single_recipient_debug_level', 194 | text='Simple text only body')) 195 | smtp.close() 196 | self.assertEqual(response, {}) 197 | 198 | def test_simple_text_only_single_recipient_short_timeout(self): 199 | timedout = False 200 | message = '' 201 | smtp = None 202 | try: 203 | # trying some unroutable ip to trigger timeout 204 | smtp = SMTP(host="10.255.255.1", port=TEST_CONFIG['SMTP_PORT'], 205 | username=TEST_CONFIG['SMTP_USER'], password=TEST_CONFIG['SMTP_PASS'], 206 | ssl=TEST_CONFIG['SSL'], tls=TEST_CONFIG['TLS'], timeout=1) 207 | smtp.login() 208 | except (socket.timeout, smtplib.SMTPServerDisconnected) as e: 209 | timedout = True 210 | message = str(e) 211 | finally: 212 | if smtp and smtp.client: 213 | smtp.close() 214 | self.assertTrue(timedout) 215 | self.assertTrue('timed out' in message) 216 | 217 | 218 | 219 | if __name__ == '__main__': 220 | print("Strudel Py Test Suite") 221 | print("="*80) 222 | unittest.main() 223 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | python -m unittest strudelpy.tests.tests --------------------------------------------------------------------------------