├── .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
--------------------------------------------------------------------------------