diff --git a/schwarz/mailqueue/cli/__init__.py b/schwarz/mailqueue/cli/__init__.py index 2eb5b91..cd2d844 100644 --- a/schwarz/mailqueue/cli/__init__.py +++ b/schwarz/mailqueue/cli/__init__.py @@ -1,3 +1,4 @@ +from .mq_sendmail import * from .one_shot_queue_run import * from .send_test_message import * diff --git a/schwarz/mailqueue/cli/mq_sendmail.py b/schwarz/mailqueue/cli/mq_sendmail.py new file mode 100644 index 0000000..6686866 --- /dev/null +++ b/schwarz/mailqueue/cli/mq_sendmail.py @@ -0,0 +1,109 @@ +""" +mq-sendmail + +Usage: + mq-sendmail [options] ... + +Options: + -C, --config= Path to the config file (default: /etc/mailqueue-runner.ini) + --set-date-header Add a "Date" header to the message (if not present) + --set-from-header Set the "From:" header in the outgoing mail based on the unix user + --set-msgid-header Add a "Message-ID" header to the message (if not present) + --verbose, -v more verbose program output +""" + +import os +import platform +import sys +from datetime import datetime as DateTime, timezone +from email.message import EmailMessage +from email.parser import BytesHeaderParser +from email.utils import format_datetime, make_msgid +from io import BytesIO + +from docopt import docopt + +from schwarz.mailqueue.app_helpers import init_app, init_smtp_mailer +from schwarz.mailqueue.message_handler import InMemoryMsg, MessageHandler + + +__all__ = ['mq_sendmail_main'] + +def mq_sendmail_main(argv=sys.argv, return_rc_code=False): + arguments = docopt(__doc__, argv=argv[1:]) + config_path = arguments['--config'] + recipients = arguments[''] + verbose = arguments['--verbose'] + + set_date_header = arguments['--set-date-header'] + set_from_header = arguments['--set-from-header'] + set_msgid_header = arguments['--set-msgid-header'] + + msg_bytes = sys.stdin.buffer.read() + smtp_sender_domain = platform.uname().node + msg_sender = os.getlogin() + '@' + smtp_sender_domain + + cli_options = { + 'verbose': verbose, + 'quiet' : not verbose, + } + settings = init_app(config_path, options=cli_options) + mailer = init_smtp_mailer(settings) + + extra_header_lines = autogenerated_headers( + set_date_header, + set_from_header, + set_msgid_header, + msg_bytes, + msg_sender, + ) + msg = InMemoryMsg(msg_sender, recipients, extra_header_lines + msg_bytes) + + mh = MessageHandler(transports=(mailer,)) + send_result = mh.send_message(msg) + + if verbose: + cli_output = build_cli_output(send_result) + print(cli_output) + was_sent = bool(send_result) + exit_code = 0 if was_sent else 100 + if return_rc_code: + return exit_code + sys.exit(exit_code) + + +def autogenerated_headers(set_date_header, set_from_header, set_msgid_header, msg_bytes, msg_sender): # noqa: E501 (line-too-long) + input_headers = BytesHeaderParser().parse(BytesIO(msg_bytes)) + extra_headers = EmailMessage() + input_msg_date = input_headers.get('Date') + if set_date_header and not input_msg_date: + extra_headers['Date'] = format_datetime(DateTime.now(timezone.utc)) + input_msg_from = input_headers.get('From') + if set_from_header and not input_msg_from: + extra_headers['From'] = msg_sender + input_msg_id = input_headers.get('Message-ID') + if set_msgid_header and not input_msg_id: + _, smtp_sender_domain = msg_sender.split('@', 1) + extra_headers['Message-ID'] = make_msgid(domain=smtp_sender_domain) + + prepended_header_lines = b'' + if extra_headers: + fake_msg_bytes = extra_headers.as_bytes() + prepended_header_lines = fake_msg_bytes.split(b'\n\n', 1)[0] + b'\n' + return prepended_header_lines + + +def build_cli_output(send_result) -> str: + was_sent = bool(send_result) + if was_sent: + if send_result.queued: + verb = 'queued' + via = f' via {send_result.transport}' + elif send_result.discarded: + verb = 'discarded' + via = '' + else: + verb = 'sent' + via = f' via {send_result.transport}' + return f'Message was {verb}{via}.' + return '' diff --git a/schwarz/mailqueue/mq_sendmail.py b/schwarz/mailqueue/mq_sendmail.py new file mode 100644 index 0000000..89397f3 --- /dev/null +++ b/schwarz/mailqueue/mq_sendmail.py @@ -0,0 +1,6 @@ + +from .cli import mq_sendmail_main + + +if __name__ == '__main__': + mq_sendmail_main() diff --git a/tests/mq_sendmail_test.py b/tests/mq_sendmail_test.py new file mode 100644 index 0000000..afeb546 --- /dev/null +++ b/tests/mq_sendmail_test.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: MIT + +import email +import re +import subprocess +import sys +import textwrap +from datetime import datetime as DateTime, timedelta as TimeDelta, timezone +from email.utils import parsedate_to_datetime + +import pytest +from dotmap import DotMap +from pymta.test_util import SMTPTestHelper + +from schwarz.mailqueue.testutils import create_ini + + +@pytest.fixture +def ctx(tmp_path): + mta_helper = SMTPTestHelper() + (hostname, listen_port) = mta_helper.start_mta() + ctx = { + 'hostname': hostname, + 'listen_port': listen_port, + 'mta': mta_helper, + 'tmp_path': tmp_path, + } + try: + yield DotMap(_dynamic=False, **ctx) + finally: + mta_helper.stop_mta() + + +def _example_message() -> str: + return textwrap.dedent(''' + To: baz@site.example + Subject: Test message + + Mail body + ''').strip() + + +def test_mq_sendmail(ctx): + rfc_msg = _example_message() + _mq_sendmail(['foo@site.example'], msg=rfc_msg, ctx=ctx) + + smtp_msg = _retrieve_sent_message(ctx.mta) + # smtp from is auto-generated from current user+host, so not easy to test + assert tuple(smtp_msg.smtp_to) == ('foo@site.example',) + assert smtp_msg.username is None # no smtp user name set in config + assert smtp_msg.msg_data == rfc_msg + + +def _retrieve_sent_message(mta): + received_queue = mta.get_received_messages() + assert received_queue.qsize() == 1 + smtp_msg = received_queue.get(block=False) + return smtp_msg + + +def test_mq_sendmail_can_add_headers(ctx): + sent_msg = _example_message() + cli_params = [ + '--set-from-header', + '--set-date-header', + '--set-msgid-header', + 'foo@site.example', + ] + _mq_sendmail(cli_params, msg=sent_msg, ctx=ctx) + + smtp_msg = _retrieve_sent_message(ctx.mta) + msg = email.message_from_string(smtp_msg.msg_data) + assert msg['To'] == 'baz@site.example' + assert _is_email_address(msg['From']) + msg_date = parsedate_to_datetime(msg['Date']) + assert _almost_now(msg_date) + assert msg['Message-ID'] + +def _almost_now(dt): + return dt - DateTime.now(timezone.utc) < TimeDelta(seconds=1) + +def _is_email_address(s): + pattern = r'^\w+@[\w.]+$' + return re.match(pattern, s) is not None + +def _mq_sendmail(cli_params, msg, *, ctx): + cfg_dir = str(ctx.tmp_path) + config_path = create_ini(ctx.hostname, ctx.listen_port, dir_path=cfg_dir) + + cli_params = [f'--config={config_path}'] + cli_params + cmd = [sys.executable, '-m', 'schwarz.mailqueue.mq_sendmail'] + cli_params + msg_bytes = msg.encode('utf-8') + proc = subprocess.run(cmd, input=msg_bytes, capture_output=True) + + if proc.stderr: + # sys.stderr.buffer.write(proc.stderr) + raise AssertionError(proc.stderr) + assert not proc.stdout + assert proc.returncode == 0