diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..c939306 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,41 @@ +name: run_tests +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: "Installs dependencies" + run: | + pip install -U pip wheel + pip install -e .[test] + - name: "Run linter" + run: make lint + tests: + name: tests + strategy: + matrix: + python: ["3.7", "3.8", "3.9", "3.10", "3.11"] + fail-fast: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: "Installs dependencies" + run: | + pip install -U pip wheel + pip install -e .[test] + - name: "Run tests" + run: make test diff --git a/.github/workflows/upload-to-pypi.yml b/.github/workflows/upload-to-pypi.yml new file mode 100644 index 0000000..600a764 --- /dev/null +++ b/.github/workflows/upload-to-pypi.yml @@ -0,0 +1,32 @@ +name: Upload to PyPI + +on: + # Triggers the workflow when a release is created + release: + types: [created] + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: "Installs dependencies" + run: | + python3 -m pip install --upgrade pip + python3 -m pip install setuptools wheel build twine + + - name: "Builds and uploads to PyPI" + run: | + python3 -m build + python3 -m twine check dist/* + python3 -m twine upload dist/* + + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_TOKEN }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100755 index cba73ed..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -dist: bionic -language: python -python: - - "3.6" - - "3.7" - - "3.8" - -before_install: pip install -U pip coveralls -install: pip install .[test] -script: pytest -Wd --cov mailshake --cov tests . -after_success: coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 2ebae7c..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -## 2.2 -Several bugfixes, internal improvements, and code cleanup, almost entirely by @Changaco - -- Prune some remnants of Python 2 compatibility -- Prevent SMTP test failures from causing deadlocks -- Clean up and refactor message rendering -- Add a `host_id` argument to `make_msgid` -- Improve the uniqueness of generated message IDs -- Add proper timeouts to SMTP tests diff --git a/mailshake/mailers/amazon_ses.py b/mailshake/mailers/amazon_ses.py index ece8f28..dd96ccc 100755 --- a/mailshake/mailers/amazon_ses.py +++ b/mailshake/mailers/amazon_ses.py @@ -20,8 +20,7 @@ def __init__( *args, **kwargs ): - """ - """ + """ """ import boto3 self.client = boto3.client( @@ -35,8 +34,7 @@ def __init__( super(AmazonSESMailer, self).__init__(*args, **kwargs) def send_messages(self, *email_messages): - """ - """ + """ """ logger = logging.getLogger("mailshake:AmazonSESMailer") if not email_messages: logger.debug("No email messages to send") diff --git a/mailshake/mailers/filebased.py b/mailshake/mailers/filebased.py index ccb8977..6f0f608 100755 --- a/mailshake/mailers/filebased.py +++ b/mailshake/mailers/filebased.py @@ -40,8 +40,7 @@ def __init__(self, path, multifile=True, *args, **kwargs): super(ToFileMailer, self).__init__(*args, **kwargs) def _get_filename(self): - """Return a unique file name. - """ + """Return a unique file name.""" if self._fname is None: now = datetime.datetime.now() timestamp = now.strftime("%Y%m%d-%H%M%S") diff --git a/mailshake/mailers/memory.py b/mailshake/mailers/memory.py index 8be93e8..289b0d6 100755 --- a/mailshake/mailers/memory.py +++ b/mailshake/mailers/memory.py @@ -17,7 +17,6 @@ def __init__(self, *args, **kwargs): super(ToMemoryMailer, self).__init__(*args, **kwargs) def send_messages(self, *email_messages): - """Redirect messages to the dummy outbox. - """ + """Redirect messages to the dummy outbox.""" self.outbox.extend(email_messages) return len(email_messages) diff --git a/mailshake/mailers/smtp.py b/mailshake/mailers/smtp.py index e8d0c17..7305aa8 100755 --- a/mailshake/mailers/smtp.py +++ b/mailshake/mailers/smtp.py @@ -87,8 +87,7 @@ def open(self, hostname=None): return True def close(self): - """Closes the connection to the email server. - """ + """Closes the connection to the email server.""" if self.connection is None: return try: @@ -125,8 +124,7 @@ def send_messages(self, *email_messages): return num_sent def _send(self, message): - """A helper method that does the actual sending. - """ + """A helper method that does the actual sending.""" recipients = message.get_recipients() if not recipients: return False diff --git a/mailshake/message.py b/mailshake/message.py index 59197ce..99a7286 100755 --- a/mailshake/message.py +++ b/mailshake/message.py @@ -86,8 +86,7 @@ def __init__( class EmailMessage: - """A container for email information. - """ + """A container for email information.""" content_subtype = "plain" mixed_subtype = "mixed" diff --git a/mailshake/utils.py b/mailshake/utils.py index b5776f1..3d2c210 100755 --- a/mailshake/utils.py +++ b/mailshake/utils.py @@ -114,8 +114,7 @@ def make_msgid(idstring=None, host_id=DNS_NAME): def forbid_multi_line_headers(name, val): - """Forbids multi-line headers, to prevent header injection. - """ + """Forbids multi-line headers, to prevent header injection.""" if "\n" in val or "\r" in val: raise ValueError( "Header values can't contain newlines " @@ -124,8 +123,7 @@ def forbid_multi_line_headers(name, val): def to_str(s, encoding="utf-8", errors="strict"): - """Force a string to be the native text_type - """ + """Force a string to be the native text_type""" if isinstance(s, str): return s return str(s, encoding, errors) diff --git a/setup.cfg b/setup.cfg index 18d34de..5f8b7b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = mailshake -version= 2.2 +version = 2.3 url = https://github.com/jpsca/mailshake project_urls = Issue tracker = https://github.com/jpsca/mailshake/issues @@ -32,8 +32,10 @@ test = flake8 pytest pytest-cov + smtpdfix dev = + black flake8 pytest pytest-cov @@ -41,23 +43,37 @@ dev = [flake8] select = - B, # bugbear - B9, # bugbear opinionated - C, # mccabe, comprehensions, commas - E, # pycodestyle errors - F, # pyflakes - G, # logging format - I, # imports + # bugbear + B, + # bugbear opinionated + B9, + # mccabe, comprehensions, commas + C, + # pycodestyle errors + E, + # pyflakes + F, + # logging format + G, + # imports + I, P, - Q, # quotes - RST, # rst docstring formatting - T0, # print - T4, # mypy - W, # pycodestyle warnings + # quotes + Q, + # rst docstring formatting + RST, + # print + T0, + # mypy + T4, + # pycodestyle warnings + W, ignore = - W503, # W503 line break before binary operator - E203, # E203 whitespace before ':' + # W503 line break before binary operator + W503, + # E203 whitespace before ':' + E203, max-complexity = 10 max-line-length = 88 diff --git a/tests/test_mailers.py b/tests/test_mailers.py index 1e280dd..664db42 100755 --- a/tests/test_mailers.py +++ b/tests/test_mailers.py @@ -81,8 +81,7 @@ def test_to_console_mailer(): def test_to_console_stream_kwarg(): - """Test that the console backend can be pointed at an arbitrary stream. - """ + """Test that the console backend can be pointed at an arbitrary stream.""" s = StringIO() mailer = ToConsoleMailer(stream=s) mailer.send("Subject", "Content", "from@example.com", "to@example.com") diff --git a/tests/test_message.py b/tests/test_message.py index 33f4a7f..d60605f 100755 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -216,8 +216,7 @@ def test_message_header_overrides(): def test_from_header(): - """Make sure we can manually set the From header. - """ + """Make sure we can manually set the From header.""" email = EmailMessage( "Subject", "Content", @@ -436,8 +435,7 @@ def test_dont_mangle_from_in_body(): def test_dont_base64_encode(): - """Shouldn't use Base64 encoding at all. - """ + """Shouldn't use Base64 encoding at all.""" email = EmailMessage( "Subject", "UTF-8 encoded body", @@ -505,15 +503,22 @@ def test_invalid_destination(): assert message["To"] != dest +rx_message_id = re.compile( + r"^<[0-9]{14}\.[0-9]+\.[0-9a-f]+\.[0-9]+@[a-z0-9\-]+(\.[a-z0-9\-]+)*>$", + re.IGNORECASE, +) + + def test_message_id(): - message_id_re = re.compile( - r"^<[0-9]{14}\.[0-9]+\.[0-9a-f]+\.[0-9]+@[a-z\-]+(\.[a-z\-]+)*>$", - re.IGNORECASE - ) email1 = EmailMessage("Subject 1", "Content", "from@example.com", "to@example.com") msg1 = email1.render() - assert message_id_re.match(msg1["Message-ID"]) + mid1 = msg1["Message-ID"] + print("Message-ID 1:", mid1) + assert rx_message_id.match(mid1) + email2 = EmailMessage("Subject 2", "Content", "from@example.com", "to@example.com") msg2 = email2.render() - assert message_id_re.match(msg2["Message-ID"]) - assert msg2["Message-ID"] != msg1["Message-ID"] + mid2 = msg2["Message-ID"] + print("Message-ID 2:", mid2) + assert rx_message_id.match(mid2) + assert mid2 != mid1 diff --git a/tests/test_smtp_mailer.py b/tests/test_smtp_mailer.py index 26e77c6..ec393ff 100755 --- a/tests/test_smtp_mailer.py +++ b/tests/test_smtp_mailer.py @@ -1,10 +1,5 @@ -import asyncore -from email import message_from_bytes -import smtpd -from smtplib import SMTPException -import threading - import pytest +from smtplib import SMTP, SMTPException from ..mailshake import EmailMessage, SMTPMailer @@ -18,69 +13,18 @@ def make_emails(): ] -smtp_server = None -SMTP_HOST = "127.0.0.1" -SMTP_PORT = 8080 - - -class FakeSMTPServer(smtpd.SMTPServer): - """A Fake smtp server""" - - def __init__(self, host, port): - print("Running fake SMTP server") - localaddr = (host, port) - remoteaddr = None - smtpd.SMTPServer.__init__(self, localaddr, remoteaddr) - self.flush_sink() - - def flush_sink(self): - self.sink = [] - - def process_message(self, peer, from_, to, bmessage, **kwargs): - self.sink.append(message_from_bytes(bmessage)) - - def start(self): - # timeout parameter is important, otherwise code will block 30 seconds after - # the SMTP channel has been closed - self.thread = threading.Thread(target=asyncore.loop, kwargs={"timeout": 0.1}) - self.thread.daemon = True - self.thread.start() - - def stop(self): - # close the SMTPserver to ensure no channels connect to asyncore - self.close() - # now it is save to wait for the thread to finish, - # i.e. for asyncore.loop() to exit - self.thread.join(timeout=0.5) - - -def setup_module(): - global smtp_server - smtp_server = FakeSMTPServer(SMTP_HOST, SMTP_PORT) - smtp_server.start() - - -def teardown_module(): - global smtp_server - if smtp_server is not None: - smtp_server.stop() - - -def test_sending(): - global smtp_server - smtp_server.flush_sink() - - mailer = SMTPMailer(host=SMTP_HOST, port=SMTP_PORT, use_tls=False) +def test_sending(smtpd): + mailer = SMTPMailer(host=smtpd.hostname, port=smtpd.port, use_tls=False) email1, email2, email3, email4 = make_emails() - assert mailer.send_messages(email1) == 1 - assert mailer.send_messages(email2, email3) == 2 - assert mailer.send_messages(email4) == 1 + with SMTP(smtpd.hostname, smtpd.port): + assert mailer.send_messages(email1) == 1 + assert mailer.send_messages(email2, email3) == 2 + assert mailer.send_messages(email4) == 1 - sink = smtp_server.sink - assert len(sink) == 4 + assert len(smtpd.messages) == 4 - message = sink[0] + message = smtpd.messages[0] print(message) assert message.get_content_type() == "text/plain" assert message.get("subject") == "Subject-1" @@ -88,77 +32,101 @@ def test_sending(): assert message.get("to") == "to@example.com" -def test_sending_unicode(): - global smtp_server - smtp_server.flush_sink() - - mailer = SMTPMailer(host="127.0.0.1", port=SMTP_PORT, use_tls=False) +def test_sending_unicode(smtpd): + mailer = SMTPMailer(host=smtpd.hostname, port=smtpd.port, use_tls=False) email = EmailMessage( - "Olé", "Contenido en español", "from@example.com", "toБ@example.com" + subject="Olé", + text="Contenido en español", + from_email="from@example.com", + to="toБ@example.com", ) - assert mailer.send_messages(email) - sink = smtp_server.sink - assert len(sink) == 1 + with SMTP(smtpd.hostname, smtpd.port): + assert mailer.send_messages(email) + + assert len(smtpd.messages) == 1 + message = smtpd.messages[0] + print(message) + assert message.get_content_type() == "text/plain" + assert message.get("subject") == "=?utf-8?q?Ol=C3=A9?=" -def test_notls(): - mailer = SMTPMailer(host="127.0.0.1", port=SMTP_PORT, use_tls=True) + +def test_notls(smtpd): + mailer = SMTPMailer(host=smtpd.hostname, port=smtpd.port, use_tls=True) with pytest.raises(SMTPException): - mailer.open() + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() -def test_wrong_host(): - mailer = SMTPMailer(host="123", port=SMTP_PORT, use_tls=False, timeout=0.5) +def test_wrong_host(smtpd): + mailer = SMTPMailer(host="123", port=smtpd.port, use_tls=False, timeout=0.5) with pytest.raises(Exception): - mailer.open() + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() -def test_wrong_port(): - mailer = SMTPMailer(host="127.0.0.1", port=3000, use_tls=False) +def test_wrong_port(smtpd): + mailer = SMTPMailer(host=smtpd.hostname, port=3000, use_tls=False) with pytest.raises(Exception): - mailer.open() + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() -def test_fail_silently(): +def test_fail_silently(smtpd): mailer = SMTPMailer( - host="127.0.0.1", port=SMTP_PORT, use_tls=True, fail_silently=True + host=smtpd.hostname, + port=smtpd.port, + use_tls=True, + fail_silently=True, ) - mailer.open() + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() mailer = SMTPMailer( - host="123", port=SMTP_PORT, use_tls=False, fail_silently=True, timeout=0.5 + host="123", + port=smtpd.port, + use_tls=False, + fail_silently=True, + timeout=0.5, ) - mailer.open() + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() - mailer = SMTPMailer(host="127.0.0.1", port=3000, use_tls=False, fail_silently=True) - mailer.open() + mailer = SMTPMailer( + host=smtpd.hostname, + port=3000, + use_tls=False, + fail_silently=True, + ) + with SMTP(smtpd.hostname, smtpd.port): + mailer.open() mailer.close() -def test_batch_too_many_recipients(): - global smtp_server - smtp_server.flush_sink() - +def test_batch_too_many_recipients(smtpd): mailer = SMTPMailer( - host="127.0.0.1", port=SMTP_PORT, use_tls=False, max_recipients=200 + host=smtpd.hostname, + port=smtpd.port, + use_tls=False, + max_recipients=200, ) send_to = ["user{}@example.com".format(i) for i in range(1, 1501)] msg = EmailMessage("The Subject", "Content", "from@example.com", send_to) - assert mailer.send_messages(msg) == 1 - sink = smtp_server.sink - assert len(sink) == 8 - - assert len(sink[0].get("to").split(",")) == 200 - assert len(sink[1].get("to").split(",")) == 200 - assert len(sink[2].get("to").split(",")) == 200 - assert len(sink[3].get("to").split(",")) == 200 - assert len(sink[4].get("to").split(",")) == 200 - assert len(sink[5].get("to").split(",")) == 200 - assert len(sink[6].get("to").split(",")) == 200 - assert len(sink[7].get("to").split(",")) == 100 + with SMTP(smtpd.hostname, smtpd.port): + assert mailer.send_messages(msg) == 1 + + assert len(smtpd.messages) == 8 + assert len(smtpd.messages[0].get("to").split(",")) == 200 + assert len(smtpd.messages[1].get("to").split(",")) == 200 + assert len(smtpd.messages[2].get("to").split(",")) == 200 + assert len(smtpd.messages[3].get("to").split(",")) == 200 + assert len(smtpd.messages[4].get("to").split(",")) == 200 + assert len(smtpd.messages[5].get("to").split(",")) == 200 + assert len(smtpd.messages[6].get("to").split(",")) == 200 + assert len(smtpd.messages[7].get("to").split(",")) == 100 diff --git a/tox.ini b/tox.ini index decdac7..f1ae285 100755 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = True -envlist = py36,py37,py38 +envlist = py37,py38,py39,py310,py311 [testenv] skip_install = true