diff --git a/.travis.yml b/.travis.yml index 083da644..76731439 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,23 +3,47 @@ sudo: false script: ./scripts/travis.sh os: - linux + +# env's are redundant, but at global scope additional jobs are created for +# each env var which I'm sure has a purpose but don't like matrix: include: - python: "3.8" - dist: "xenial" + dist: "focal" sudo: true + env: + - DOCKER_COMPOSE_VERSION=v2.17.3 + - SMTP_HOST=localhost + - SMTP_PORT=1025 - python: "3.9" - dist: "xenial" + dist: "focal" sudo: true + env: + - DOCKER_COMPOSE_VERSION=v2.17.3 + - SMTP_HOST=localhost + - SMTP_PORT=1025 - python: "3.10" dist: "focal" sudo: true + env: + - DOCKER_COMPOSE_VERSION=v2.17.3 + - SMTP_HOST=localhost + - SMTP_PORT=1025 - python: "3.11" dist: "focal" sudo: true + env: + - DOCKER_COMPOSE_VERSION=v2.17.3 + - SMTP_HOST=localhost + - SMTP_PORT=1025 - python: "3.12" dist: "jammy" sudo: true + env: + - DOCKER_COMPOSE_VERSION=v2.17.3 + - SMTP_HOST=localhost + - SMTP_PORT=1025 services: - memcached - redis-server + - docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2d1bbc..55044462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,27 @@ Bugs: - [Issue #310](https://github.com/datafolklabs/cement/issues/310) - `[core.foundation]` Quiet mode file is never closed - [Issue #653](https://github.com/datafolklabs/cement/issues/653) +- `[ext.smtp]` Ability to Enable TLS without SSL + - [Issue #667](https://github.com/datafolklabs/cement/issues/667) +- `[ext.smtp]` Empty (wrong) addresses sent when CC/BCC is `None` + - [Issue #668](https://github.com/datafolklabs/cement/issues/668) + - Features: - `[utils.fs]` Add Timestamp Support to fs.backup - [Issue #611](https://github.com/datafolklabs/cement/issues/611) +- `[ext.smtp]` Support for sending file attachements. + - [PR #669](https://github.com/datafolklabs/cement/pull/669) +- `[ext.smtp]` Support for sending both Plain Text and HTML + - [PR #669](https://github.com/datafolklabs/cement/pull/669) Refactoring: - `[core.plugin]` Deprecate the use of `imp` in favor of `importlib` - [Issue #386](https://github.com/datafolklabs/cement/issues/386) +- `[ext.smtp]` Actually test SMTP against a real server (replace mocks) Misc: @@ -34,7 +43,7 @@ Misc: - `[dev]` Add `comply-typing` to make helpers, start working toward typing. - [Issue #599](https://github.com/datafolklabs/cement/issues/661) - [PR #628](https://github.com/datafolklabs/cement/pull/628) - +- `[dev]` Add `mailpit` service to docker-compose development config. Deprecations: diff --git a/cement/ext/ext_smtp.py b/cement/ext/ext_smtp.py index 2adb2911..585eb3f1 100644 --- a/cement/ext/ext_smtp.py +++ b/cement/ext/ext_smtp.py @@ -2,7 +2,6 @@ Cement smtp extension module. """ -import os import smtplib from email.header import Header from email.mime.multipart import MIMEMultipart @@ -10,8 +9,10 @@ from email.mime.base import MIMEBase from email.mime.text import MIMEText from email import encoders from ..core import mail +from ..utils import fs from ..utils.misc import minimal_logger, is_true + LOG = minimal_logger(__name__) @@ -74,7 +75,10 @@ class SMTPMailHandler(mail.MailHandler): configuration defaults (cc, bcc, etc). Args: - body: The message body to send + body (list): The message body to send. List is treated as: + ``[, ]``. If a single string is passed it will be + converted to ``[]``. At minimum, a text version is + required. Keyword Args: to (list): List of recipients (generally email addresses) @@ -82,6 +86,9 @@ class SMTPMailHandler(mail.MailHandler): cc (list): List of CC Recipients bcc (list): List of BCC Recipients subject (str): Message subject line + subject_prefix (str): Prefix for message subject line (useful to + override if you want to remove/change the default prefix). + files (list): List of file paths to attach to the message. Returns: bool:``True`` if message is sent successfully, ``False`` otherwise @@ -107,7 +114,7 @@ class SMTPMailHandler(mail.MailHandler): if is_true(params['ssl']): server = smtplib.SMTP_SSL(params['host'], params['port'], params['timeout']) - LOG.debug("%s : initiating ssl" % self._meta.label) + LOG.debug("%s : initiating smtp over ssl" % self._meta.label) else: server = smtplib.SMTP(params['host'], params['port'], @@ -143,45 +150,61 @@ class SMTPMailHandler(mail.MailHandler): else: subject = params['subject'] msg['Subject'] = Header(subject) + # add body as text and or or as html partText = None partHtml = None if isinstance(body, str): partText = MIMEText(body) elif isinstance(body, list): + # handle plain text if len(body) >= 1: partText = MIMEText(body[0], 'plain') + + # handle html if len(body) >= 2: partHtml = MIMEText(body[1], 'html') - elif isinstance(body, dict): - if 'text' in body: - partText = MIMEText(body['text'], 'plain') - if 'html' in body: - partHtml = MIMEText(body['html'], 'html') + if partText: msg.attach(partText) if partHtml: msg.attach(partHtml) - # loop files + + # attach files if params['files']: - for path in params['files']: + for in_path in params['files']: part = MIMEBase('application', 'octet-stream') - # test filename for a seperate attachement disposition name + + # support for alternative file name if its tuple # like (filename.ext=attname.ext) - filename = os.path.basename(path) - # test for divider in filename - i = filename.find('=') - # split attname from filename - if i < 0: - attname = filename + if isinstance(in_path, tuple): + attname = in_path[0] + path = in_path[1] else: - attname = filename[i+1:] - filename = filename[0:i] - # update the filename to read from - path = os.path.dirname(path) + '/' + filename + attname = in_path + path = in_path + + path = fs.abspath(path) + + # filename = os.path.basename(path) + + # # test for divider in filename + # i = filename.find('=') + + # # split attname from filename + # if i < 0: + # attname = filename + # else: + # attname = filename[i+1:] + # filename = filename[0:i] + + # # update the filename to read from + # path = fs.join(os.path.dirname(path), filename) + # add attachment with open(path, 'rb') as file: part.set_payload(file.read()) + # encode and name encoders.encode_base64(part) part.add_header( diff --git a/docker-compose.yml b/docker-compose.yml index 048fc57f..a8bd286d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,80 +1,104 @@ version: "3" + +volumes: + mailpit-data: + services: - cement: &DEFAULTS - image: "cement:dev" - build: - context: . - dockerfile: docker/Dockerfile.dev - hostname: cement - stdin_open: true - tty: true - volumes: - - '.:/src' - working_dir: '/src' - links: - - redis:redis - - memcached:memcached - environment: - REDIS_HOST: redis - MEMCACHED_HOST: memcached + cement: &DEFAULTS + image: "cement:dev" + build: + context: . + dockerfile: docker/Dockerfile.dev + hostname: cement + stdin_open: true + tty: true + volumes: + - '.:/src' + working_dir: '/src' + links: + - redis:redis + - memcached:memcached + environment: + REDIS_HOST: redis + MEMCACHED_HOST: memcached + SMTP_HOST: mailpit + SMTP_PORT: 1025 - cement-py35: - <<: *DEFAULTS - image: "cement:dev-py35" - build: - context: . - dockerfile: docker/Dockerfile.dev-py35 - profiles: - - donotstart + cement-py35: + <<: *DEFAULTS + image: "cement:dev-py35" + build: + context: . + dockerfile: docker/Dockerfile.dev-py35 + profiles: + - donotstart - cement-py36: - <<: *DEFAULTS - image: "cement:dev-py36" - build: - context: . - dockerfile: docker/Dockerfile.dev-py36 - profiles: - - donotstart + cement-py36: + <<: *DEFAULTS + image: "cement:dev-py36" + build: + context: . + dockerfile: docker/Dockerfile.dev-py36 + profiles: + - donotstart - cement-py37: - <<: *DEFAULTS - image: "cement:dev-py37" - build: - context: . - dockerfile: docker/Dockerfile.dev-py37 - - cement-py38: - <<: *DEFAULTS - image: "cement:dev-py38" - build: - context: . - dockerfile: docker/Dockerfile.dev-py38 - - cement-py39: - <<: *DEFAULTS - image: "cement:dev-py39" - build: - context: . - dockerfile: docker/Dockerfile.dev-py39 - - cement-py310: - <<: *DEFAULTS - image: "cement:dev-py310" - build: - context: . - dockerfile: docker/Dockerfile.dev-py310 + cement-py37: + <<: *DEFAULTS + image: "cement:dev-py37" + build: + context: . + dockerfile: docker/Dockerfile.dev-py37 + + cement-py38: + <<: *DEFAULTS + image: "cement:dev-py38" + build: + context: . + dockerfile: docker/Dockerfile.dev-py38 + + cement-py39: + <<: *DEFAULTS + image: "cement:dev-py39" + build: + context: . + dockerfile: docker/Dockerfile.dev-py39 + + cement-py310: + <<: *DEFAULTS + image: "cement:dev-py310" + build: + context: . + dockerfile: docker/Dockerfile.dev-py310 - cement-py311: - <<: *DEFAULTS - image: "cement:dev-py311" - build: - context: . - dockerfile: docker/Dockerfile.dev-py311 + cement-py311: + <<: *DEFAULTS + image: "cement:dev-py311" + build: + context: . + dockerfile: docker/Dockerfile.dev-py311 - redis: - image: redis:latest - hostname: redis + redis: + image: redis:latest + hostname: redis - memcached: - image: memcached:latest - hostname: memcached + memcached: + image: memcached:latest + hostname: memcached + + mailpit: + image: axllent/mailpit + container_name: mailpit + restart: always + volumes: + - mailpit-data:/data + - ./docker/mailpit:/certificates + ports: + - 8025:8025 + - 1025:1025 + environment: + MP_MAX_MESSAGES: 5000 + MP_DATA_FILE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + MP_SMTP_TLS_CERT: /certificates/dev-cert.pem + MP_SMTP_TLS_KEY: /certificates/dev-key.pem diff --git a/docker/mailpit/dev-cert.pem b/docker/mailpit/dev-cert.pem new file mode 100644 index 00000000..5dfb9965 --- /dev/null +++ b/docker/mailpit/dev-cert.pem @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +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 +mjErtgV+K4j0gVg4kaci6kXx1g== +-----END CERTIFICATE----- diff --git a/docker/mailpit/dev-key.pem b/docker/mailpit/dev-key.pem new file mode 100644 index 00000000..c35642b2 --- /dev/null +++ b/docker/mailpit/dev-key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +mDpNhf5uD/yZ9rYY6wjJ4nQYBTTJpQ== +-----END PRIVATE KEY----- diff --git a/scripts/travis.sh b/scripts/travis.sh index 8c7d2900..85316e65 100755 --- a/scripts/travis.sh +++ b/scripts/travis.sh @@ -6,6 +6,10 @@ set -e # https://travis-ci.community/t/build-error-for-python-3-7-on-two-different-projects/12895/3 # pip install -U importlib_metadata +docker-compose up -d mailpit 2>&1 >/dev/null + +sleep 10 + rm -f .coverage pip install -r requirements-dev.txt make test diff --git a/tests/conftest.py b/tests/conftest.py index aba3f11d..671e2b72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,4 +23,4 @@ def key(request): @pytest.fixture(scope="function") def rando(request): - yield _rando() + yield _rando()[:12] diff --git a/tests/ext/test_ext_smtp.py b/tests/ext/test_ext_smtp.py index 8fa086ba..e2ba214f 100644 --- a/tests/ext/test_ext_smtp.py +++ b/tests/ext/test_ext_smtp.py @@ -1,8 +1,26 @@ +import os import mock +import requests +import json +from time import sleep from cement.utils.test import TestApp from cement.utils.misc import init_defaults +if 'SMTP_HOST' in os.environ.keys(): + smtp_host = os.environ['SMTP_HOST'] +else: + smtp_host = 'mailpit' + + +mailpit_api = f'http://{smtp_host}:8025/api/v1' +defaults = init_defaults('mail.smtp') +defaults['mail.smtp']['host'] = smtp_host +defaults['mail.smtp']['port'] = 1025 +defaults['mail.smtp']['to'] = 'noreply@localhost' +defaults['mail.smtp']['from_addr'] = 'nobody@localhost' +defaults['mail.smtp']['subject_prefix'] = 'UNIT TEST >' + class SMTPApp(TestApp): class Meta: @@ -10,14 +28,167 @@ class SMTPApp(TestApp): mail_handler = 'smtp' -def test_smtp_defaults(): +def delete_msg(message_id): + payload = { + "ids": [ + message_id + ] + } + payload = json.dumps(payload) + + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + } + requests.delete(f"{mailpit_api}/messages", data=payload, headers=headers) + + +def test_smtp_send(rando): + defaults['mail.smtp']['subject'] = rando + + with SMTPApp(config_defaults=defaults) as app: + app.run() + + app.mail.send(f"{rando}", + to=[f'to-{rando}@localhost'], + from_addr=f'from-{rando}@localhost') + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + assert msg['From']['Address'] == f'from-{rando}@localhost' + assert msg['To'][0]['Address'] == f'to-{rando}@localhost' + assert msg['Subject'] == f'UNIT TEST > {rando}' + assert msg['Attachments'] == 0 + assert msg['Cc'] == [] + assert msg['Bcc'] == [] + + delete_msg(msg['ID']) + + +def test_smtp_html(rando): + defaults['mail.smtp']['subject'] = rando + + with SMTPApp(config_defaults=defaults) as app: + app.run() + + app.mail.send([f"{rando}", f"{rando}"]) + sleep(3) + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + text_url = f"http://{smtp_host}:8025/view/{msg['ID']}.txt" + res_text = requests.get(text_url) + assert res_text.content.decode('utf-8') == rando + + html_url = f"http://{smtp_host}:8025/view/{msg['ID']}.html" + res_html = requests.get(html_url) + assert res_html.content.decode('utf-8') == f"{rando}" + + delete_msg(msg['ID']) + + +def test_smtp_cc_bcc(rando): + defaults['mail.smtp']['subject'] = rando + + with SMTPApp(config_defaults=defaults) as app: + app.run() + + cc = [f"cc1-{rando}@localhost", f"cc2-{rando}@localhost"] + bcc = [f"bcc1-{rando}@localhost", f"bcc2-{rando}@localhost"] + app.mail.send(f"{rando}", + cc=cc, + bcc=bcc) + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + cc_verify = [x['Address'] for x in msg['Cc']] + bcc_verify = [x['Address'] for x in msg['Bcc']] + assert f"cc1-{rando}@localhost" in cc_verify + assert f"cc2-{rando}@localhost" in cc_verify + assert f"bcc1-{rando}@localhost" in bcc_verify + assert f"bcc2-{rando}@localhost" in bcc_verify + + delete_msg(msg['ID']) + + +def test_smtp_files(rando, tmp): + defaults['mail.smtp']['subject'] = rando + + with SMTPApp(config_defaults=defaults) as app: + app.run() + + files = [] + for iter in [1, 2, 3]: + _file = f"{tmp.file}-{iter}" + with open(_file, 'w') as _open_file: + _open_file.write(f"{rando}-{iter}") + files.append(_file) + + app.mail.send(f"{rando}", files=files) + sleep(3) + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + assert msg['Attachments'] == 3 + + delete_msg(msg['ID']) + + +def test_smtp_files_alt_name(rando, tmp): + defaults['mail.smtp']['subject'] = rando + + with SMTPApp(config_defaults=defaults) as app: + app.run() + + files = [(f"alt-filename-{rando}", tmp.file)] + app.mail.send(f"{rando}", files=files) + sleep(3) + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + assert msg['Attachments'] == 1 + + res_full = requests.get(f"{mailpit_api}/message/{msg['ID']}") + data = res_full.json() + assert data['Attachments'][0]['FileName'] == f"alt-filename-{rando}" + delete_msg(msg['ID']) + + +def test_smtp_tls(rando): + defaults['mail.smtp']['subject'] = rando + defaults['mail.smtp']['tls'] = True + + with SMTPApp(config_defaults=defaults, debug=True) as app: + app.run() + app.mail.send(rando) + res = requests.get(f"{mailpit_api}/search?query={rando}") + data = res.json() + assert len(data['messages']) == 1 + msg = data['messages'][0] + + delete_msg(msg['ID']) + + +# FIXME: need to replace old mocks with mailpit tests + +def test_mock_smtp_defaults(): defaults = init_defaults('mail.smtp') - defaults['mail.smtp']['to'] = 'nobody@localhost' + defaults['mail.smtp']['to'] = 'test_smtp_defaults@localhost' defaults['mail.smtp']['from_addr'] = 'nobody@localhost' defaults['mail.smtp']['cc'] = 'nobody@localhost' defaults['mail.smtp']['bcc'] = 'nobody@localhost' - defaults['mail.smtp']['subject'] = 'Test Email To nobody@localhost' - defaults['mail.smtp']['subject_prefix'] = 'PREFIX >' + defaults['mail.smtp']['subject'] = 'test_smtp_defaults' + defaults['mail.smtp']['subject_prefix'] = 'UNIT TEST >' with mock.patch('smtplib.SMTP') as mock_smtp: with SMTPApp(config_defaults=defaults) as app: @@ -28,17 +199,38 @@ def test_smtp_defaults(): assert instance.send_message.call_count == 1 -def test_smtp_ssl_tls(): +def test_mock_smtp_ssl(): defaults = init_defaults('mail.smtp') defaults['mail.smtp']['ssl'] = True defaults['mail.smtp']['tls'] = True defaults['mail.smtp']['port'] = 25 + defaults['mail.smtp']['subject'] = 'test_smtp_ssl' + defaults['mail.smtp']['subject_prefix'] = 'UNIT TEST >' with mock.patch('smtplib.SMTP_SSL') as mock_smtp: with SMTPApp(config_defaults=defaults, debug=True) as app: app.run() app.mail.send('TEST MESSAGE', - to=['me@localhost'], + to=['test_smtp_ssl@localhost'], + from_addr='noreply@localhost') + + instance = mock_smtp.return_value + assert instance.send_message.call_count == 1 + + +def test_mock_smtp_tls_no_ssl(): + defaults = init_defaults('mail.smtp') + defaults['mail.smtp']['ssl'] = False + defaults['mail.smtp']['tls'] = True + defaults['mail.smtp']['port'] = 25 + defaults['mail.smtp']['subject'] = 'test_smtp_tls_no_ssl' + defaults['mail.smtp']['subject_prefix'] = 'UNIT TEST >' + + with mock.patch('smtplib.SMTP') as mock_smtp: + with SMTPApp(config_defaults=defaults, debug=True) as app: + app.run() + app.mail.send('TEST MESSAGE', + to=['test_smtp_tls_no_ssl@localhost'], from_addr='noreply@localhost') instance = mock_smtp.return_value @@ -46,7 +238,27 @@ def test_smtp_ssl_tls(): assert instance.starttls.call_count == 1 -def test_smtp_auth(rando): +def test_mock_smtp_tls_over_ssl(): + defaults = init_defaults('mail.smtp') + defaults['mail.smtp']['ssl'] = True + defaults['mail.smtp']['tls'] = True + defaults['mail.smtp']['port'] = 25 + defaults['mail.smtp']['subject'] = 'test_smtp_tls_over_ssl' + defaults['mail.smtp']['subject_prefix'] = 'UNIT TEST >' + + with mock.patch('smtplib.SMTP_SSL') as mock_smtp: + with SMTPApp(config_defaults=defaults, debug=True) as app: + app.run() + app.mail.send('TEST MESSAGE', + to=['test_smtp_tls_no_ssl@localhost'], + from_addr='noreply@localhost') + + instance = mock_smtp.return_value + assert instance.send_message.call_count == 1 + assert instance.starttls.call_count == 1 + + +def test_mock_smtp_auth(rando): defaults = init_defaults(rando, 'mail.smtp') defaults[rando]['debug'] = True defaults['mail.smtp']['auth'] = True