Skip to content

Commit

Permalink
add basic implementation for a mq-sendmail script
Browse files Browse the repository at this point in the history
This script is intended as an alternative to `/usr/bin/sendmail`
and `/usr/bin/msmtp` (albeit with less functionality).

The idea is to provide local queueing in a robust manner using
the code from mailqueue-runner. "msmtp" queueing is implemented
by some community-contributed bash scripts which I do not find
particularly trust inspiring. Other implementations like "ssmtp"
do not support queueing at all or require a full-blown mail server
(Exim, postfix).
  • Loading branch information
FelixSchwarz committed Aug 5, 2024
1 parent 18eece3 commit 5060eac
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 0 deletions.
1 change: 1 addition & 0 deletions schwarz/mailqueue/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

from .mq_sendmail import *
from .one_shot_queue_run import *
from .send_test_message import *
109 changes: 109 additions & 0 deletions schwarz/mailqueue/cli/mq_sendmail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""
mq-sendmail
Usage:
mq-sendmail [options] <recipients>...
Options:
-C, --config=<CFG> 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['<recipients>']
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 ''
6 changes: 6 additions & 0 deletions schwarz/mailqueue/mq_sendmail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

from .cli import mq_sendmail_main


if __name__ == '__main__':
mq_sendmail_main()
99 changes: 99 additions & 0 deletions tests/mq_sendmail_test.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 5060eac

Please sign in to comment.