-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add basic implementation for a
mq-sendmail
script
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
1 parent
18eece3
commit 5060eac
Showing
4 changed files
with
215 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 * |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 '' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |