diff --git a/scripts/mail-check.py b/scripts/mail-check.py new file mode 100644 index 0000000..47629fb --- /dev/null +++ b/scripts/mail-check.py @@ -0,0 +1,187 @@ +import smtplib, sys +import argparse +import os +import uuid +import imaplib +from datetime import datetime, timedelta +import email +import time + +RETRY = 100 + +def _send_mail(smtp_host, smtp_port, from_addr, from_pwd, to_addr, subject, starttls): + print("Sending mail with subject '{}'".format(subject)) + message = "\n".join([ + "From: {from_addr}", + "To: {to_addr}", + "Subject: {subject}", + "", + "This validates our mail server can send to Gmail :/"]).format( + from_addr=from_addr, + to_addr=to_addr, + subject=subject) + + + retry = RETRY + while True: + with smtplib.SMTP(smtp_host, port=smtp_port) as smtp: + if starttls: + smtp.starttls() + if from_pwd is not None: + smtp.login(from_addr, from_pwd) + try: + smtp.sendmail(from_addr, [to_addr], message) + return + except smtplib.SMTPResponseException as e: + # This is a service unavailable error + # In this situation, we want to retry. + if e.smtp_code == 451: + if retry > 0: + retry = retry - 1 + time.sleep(1) + continue + else: + print("Error while sending mail: %s" % e) + exit(5) + except Exception as e: + print("Error while sending mail: %s" % e) + exit(4) + +def _read_mail( + imap_host, + imap_port, + imap_username, + to_pwd, + subject, + ignore_dkim_spf, + show_body=False, + delete=True): + print("Reading mail from %s" % imap_username) + + message = None + + obj = imaplib.IMAP4_SSL(imap_host, imap_port) + obj.login(imap_username, to_pwd) + obj.select() + + today = datetime.today() + cutoff = today - timedelta(days=1) + dt = cutoff.strftime('%d-%b-%Y') + for _ in range(0, RETRY): + print("Retrying") + obj.select() + typ, data = obj.search(None, '(SINCE %s) (SUBJECT "%s")'%(dt, subject)) + if data == [b'']: + time.sleep(1) + continue + + uids = data[0].decode("utf-8").split(" ") + if len(uids) != 1: + print("Warning: %d messages have been found with subject containing %s " % (len(uids), subject)) + + # FIXME: we only consider the first matching message... + uid = uids[0] + _, raw = obj.fetch(uid, '(RFC822)') + if delete: + obj.store(uid, '+FLAGS', '\\Deleted') + obj.expunge() + message = email.message_from_bytes(raw[0][1]) + print("Message with subject '%s' has been found" % message['subject']) + if show_body: + for m in message.get_payload(): + if m.get_content_type() == 'text/plain': + print("Body:\n%s" % m.get_payload(decode=True).decode('utf-8')) + break + + if message is None: + print("Error: no message with subject '%s' has been found in INBOX of %s" % (subject, imap_username)) + exit(1) + + if ignore_dkim_spf: + return + + # gmail set this standardized header + if 'ARC-Authentication-Results' in message: + if "dkim=pass" in message['ARC-Authentication-Results']: + print("DKIM ok") + else: + print("Error: no DKIM validation found in message:") + print(message.as_string()) + exit(2) + if "spf=pass" in message['ARC-Authentication-Results']: + print("SPF ok") + else: + print("Error: no SPF validation found in message:") + print(message.as_string()) + exit(3) + else: + print("DKIM and SPF verification failed") + exit(4) + +def send_and_read(args): + src_pwd = None + if args.src_password_file is not None: + src_pwd = args.src_password_file.readline().rstrip() + dst_pwd = args.dst_password_file.readline().rstrip() + + if args.imap_username != '': + imap_username = args.imap_username + else: + imap_username = args.to_addr + + subject = "{}".format(uuid.uuid4()) + + _send_mail(smtp_host=args.smtp_host, + smtp_port=args.smtp_port, + from_addr=args.from_addr, + from_pwd=src_pwd, + to_addr=args.to_addr, + subject=subject, + starttls=args.smtp_starttls) + + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + imap_username=imap_username, + to_pwd=dst_pwd, + subject=subject, + ignore_dkim_spf=args.ignore_dkim_spf) + +def read(args): + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + to_addr=args.imap_username, + to_pwd=args.imap_password, + subject=args.subject, + ignore_dkim_spf=args.ignore_dkim_spf, + show_body=args.show_body, + delete=False) + +parser = argparse.ArgumentParser() +subparsers = parser.add_subparsers() + +parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.") +parser_send_and_read.add_argument('--smtp-host', type=str) +parser_send_and_read.add_argument('--smtp-port', type=str, default=25) +parser_send_and_read.add_argument('--smtp-starttls', action='store_true') +parser_send_and_read.add_argument('--from-addr', type=str) +parser_send_and_read.add_argument('--imap-host', required=True, type=str) +parser_send_and_read.add_argument('--imap-port', type=str, default=993) +parser_send_and_read.add_argument('--to-addr', type=str, required=True) +parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used") +parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r')) +parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r')) +parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") +parser_send_and_read.set_defaults(func=send_and_read) + +parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.") +parser_read.add_argument('--imap-host', type=str, default="localhost") +parser_read.add_argument('--imap-port', type=str, default=993) +parser_read.add_argument('--imap-username', required=True, type=str) +parser_read.add_argument('--imap-password', required=True, type=str) +parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") +parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload") +parser_read.add_argument('subject', type=str) +parser_read.set_defaults(func=read) + +args = parser.parse_args() +args.func(args) diff --git a/tests/default.nix b/tests/default.nix index 1e1b67b..e78a96f 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -35,6 +35,7 @@ let "intern" "extern" "clamav" + "multiple" ]; # Generate an attribute set containing one test per releases diff --git a/tests/multiple.nix b/tests/multiple.nix new file mode 100644 index 0000000..c6a4edf --- /dev/null +++ b/tests/multiple.nix @@ -0,0 +1,87 @@ +# This tests is used to test features requiring several mail domains. + +{ pkgs ? import {}}: + +let + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.mkpasswd ]; } + '' + mkpasswd -m sha-512 ${password} > $out + ''; + + password = pkgs.writeText "password" "password"; + + domainGenerator = domain: { config, pkgs, ... }: { + imports = [../default.nix]; + virtualisation.memorySize = 1024; + mailserver = { + enable = true; + fqdn = "mail.${domain}"; + domains = [ domain ]; + localDnsResolver = false; + loginAccounts = { + "user@${domain}" = { + hashedPasswordFile = hashPassword "password"; + }; + }; + enableImap = true; + enableImapSsl = true; + }; + services.dnsmasq = { + enable = true; + extraConfig = '' + mx-host=domain1.com,domain1,10 + mx-host=domain2.com,domain2,10 + ''; + }; + }; + +in + +pkgs.nixosTest { + name = "multiple"; + nodes = { + domain1 = {...}: { + imports = [ + ../default.nix + (domainGenerator "domain1.com") + ]; + mailserver.forwards = { + "non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"]; + "non@domain1.com" = ["user@domain2.com" "user@domain1.com"]; + }; + }; + domain2 = domainGenerator "domain2.com"; + client = { config, pkgs, ... }: { + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; + }; + }; + testScript = '' + start_all() + + domain1.wait_for_unit("multi-user.target") + domain2.wait_for_unit("multi-user.target") + + # TODO put this blocking into the systemd units? + domain1.wait_until_succeeds( + "timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + ) + domain2.wait_until_succeeds( + "timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + ) + + # user@domain1.com sends a mail to user@domain2.com + client.succeed( + "mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" + ) + + # Send a mail to the address forwarded and check it is in the recipient mailbox + client.succeed( + "mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" + ) + ''; +}