diff --git a/.gitignore b/.gitignore index 7fdea582cc791299364567e2feb93c2cbe47390b..2eab600efceee5fe770bfd009c14bc617e550a25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ *.pyc *.egg-info +.coverage +.tox +build +dist +coverage.xml +nosetests.xml +htmlcov diff --git a/Dockerfile b/Dockerfile index 69d9f7966654e5322cce83afdf8610e690bf35e0..617c40871c15d2edacab298cf46d0099523322d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,10 @@ FROM registry.git.autistici.org/ai3/docker/s6-base:master RUN apt-get -q update && \ env DEBIAN_FRONTEND=noninteractive apt-get -qy install --no-install-recommends \ - python3-flask python3-sqlalchemy python3-flask-sqlalchemy python3-flask-script \ - python3-setuptools ca-certificates + python3-flask python3-sqlalchemy python3-flask-sqlalchemy \ + python3-setuptools ca-certificates && \ + apt-get clean && \ + rm -fr /var/lib/apt/lists/* ADD . /tmp/src WORKDIR /tmp/src diff --git a/feedbackloop/debug.py b/feedbackloop/debug.py new file mode 100644 index 0000000000000000000000000000000000000000..d448e4a00f4dcf100c006c592f7679bbdbbcb55f --- /dev/null +++ b/feedbackloop/debug.py @@ -0,0 +1,18 @@ +import mailbox + + +class DebugMbox(object): + + def __init__(self, path): + self._path = path + + def write_message(self, msg): + if not self._path: + return + mbox = mailbox.mbox(self._path) + mbox.lock() + try: + mbox.add(msg) + finally: + mbox.unlock() + mbox.close() diff --git a/feedbackloop/main.py b/feedbackloop/main.py index 1e1dca2b744e791ff909d8d1bd7569bd2a30fe35..257d9392b48af2c4d1d6d2fb42bb7fa57013d18d 100644 --- a/feedbackloop/main.py +++ b/feedbackloop/main.py @@ -1,6 +1,6 @@ from cheroot import wsgi from datetime import datetime, timedelta -from flask_script import Manager +import click from .app import app, db @@ -8,23 +8,19 @@ from .app import app, db from . import views from .model import FeedbackEntry from .parse import MailScanner +from .debug import DebugMbox def create_app(config=None): app.config.from_envvar('APP_CONFIG', silent=True) if config: app.config.update(config) - - db.create_all() - return app -manager = Manager(create_app) - - -@manager.option('--unseen', action='store_true') -@manager.option('--limit', type=int, default=0) +@app.cli.command('ingest') +@click.option('--unseen', is_flag=True, default=False) +@click.option('--limit', type=int, default=0) def ingest(unseen, limit): """Read and ingest new messages from the IMAP mailbox.""" with app.app_context(): @@ -33,13 +29,16 @@ def ingest(unseen, limit): app.config['IMAP_USERNAME'], app.config['IMAP_PASSWORD'], app.config.get('IMAP_REMOVE_MESSAGES', True), - app.config.get('LIST_DOMAINS')) as scanner: + app.config.get('LIST_DOMAINS'), + DebugMbox(app.config.get('DEBUG_MBOX_PATH')), + ) as scanner: for entry in scanner.scan(unseen, limit): db.session.add(entry) db.session.commit() -@manager.option('--days', type=int, default=30) +@app.cli.command('expire') +@click.option('--days', type=int, default=30) def expire(days): """Expire old entries from the database.""" cutoff = datetime.now() - timedelta(days) @@ -50,15 +49,10 @@ def expire(days): db.session.commit() -@manager.option('--addr', default='0.0.0.0') -@manager.option('--port', type=int, default=3030) +@app.cli.command('server') +@click.option('--addr', default='0.0.0.0') +@click.option('--port', type=int, default=3030) def server(addr, port): - wsgi.Server((addr, port), app).start() - - -def main(): - manager.run() - + db.create_all() -if __name__ == '__main__': - main() + wsgi.Server((addr, port), app).start() diff --git a/feedbackloop/parse.py b/feedbackloop/parse.py index 7d852eafbcf4da6014bb1aa9a739403e35f804d4..15a2ae759d9aba06e7c7279bbbb0eea295014259 100644 --- a/feedbackloop/parse.py +++ b/feedbackloop/parse.py @@ -1,6 +1,5 @@ #!/usr/bin/python3 -import argparse import imaplib import re import time @@ -55,13 +54,14 @@ class MailScanner(): """ def __init__(self, server, username, password, remove=True, - list_domains=None): + list_domains=None, debug_mbox=None): self._server = server self._username = username self._password = password self._remove = remove self._to_delete = [] self._conn = None + self._debug_mbox = debug_mbox if list_domains: self._list_id_rx = re.compile( r'<(.+)\.(%s)>' % ( @@ -102,7 +102,8 @@ class MailScanner(): def _scan_mailbox(self, unseen=False, limit=None): i = 0 - res, data = self._conn.uid('search', None, '(UNSEEN)' if unseen else 'ALL') + res, data = self._conn.uid('search', None, + '(UNSEEN)' if unseen else 'ALL') if res != 'OK': raise IMAPError(res) for uid in data[0].decode('utf-8').split(): @@ -122,6 +123,8 @@ class MailScanner(): yield entry except Exception as e: print(f'error: {e}') + if self._debug_mbox: + self._debug_mbox.write_message(raw_msg) def _parse_message(self, raw_msg): # Parse the ARF message body. @@ -150,11 +153,13 @@ class MailScanner(): timestamp = datetime.fromtimestamp( time.mktime(parsedate(_hdr(hdrs, 'Date')))) - fr = msg.get_feedback_report() - if fr: - print(f'feedback report: {fr.get_feedback_type()} - {fr.get_original_mail_from()}') - original_from = fr.get_original_mail_from() - arrival_date = fr.get_arrival_date() + feedback_report = msg.get_feedback_report() + if feedback_report: + print('feedback report: %s - %s' % ( + feedback_report.get_feedback_type(), + feedback_report.get_original_mail_from())) + original_from = feedback_report.get_original_mail_from() + arrival_date = feedback_report.get_arrival_date() if arrival_date: timestamp = datetime.fromtimestamp( time.mktime(parsedate(arrival_date))) @@ -188,20 +193,3 @@ class MailScanner(): timestamp=timestamp, message=raw_msg, ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument('--user') - parser.add_argument('--password') - parser.add_argument('--server', default='mail.autistici.org') - parser.add_argument('--unseen', action='store_true') - args = parser.parse_args() - - with MailScanner(args.server, args.user, args.password, False) as scanner: - for entry in scanner.scan(args.unseen, 20): - print(entry) - - -if __name__ == '__main__': - main() diff --git a/feedbackloop/test/__init__.py b/feedbackloop/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..96cc911860b1b5047a44fc8be2a3a5d135417316 --- /dev/null +++ b/feedbackloop/test/__init__.py @@ -0,0 +1,38 @@ + +TEST_MESSAGE = b'''From: <abusedesk@example.com> +Date: Thu, 8 Mar 2005 17:40:36 EDT +Subject: FW: Earn money +To: <abuse@example.net> +MIME-Version: 1.0 +Content-Type: multipart/report; report-type=feedback-report; + boundary="part1_13d.2e68ed54_boundary" + +--part1_13d.2e68ed54_boundary +Content-Disposition: inline +Content-Transfer-Encoding: 8bit +Content-Type: message/feedback-report + +Version: 1 +Original-Rcpt-To: c8d8eba9466e8d59deca1a60a1ec9bae@example.com +Reported-Domain: example.net +Source: Example +Abuse-Type: complaint +Feedback-Type: abuse +User-Agent: ReturnPathFBL/2.0 +Arrival-Date: Thu, 20 Oct 2022 14:45:15 +0000 +Original-Mail-From: foo@example.net +Source-Ip: 1.2.3.4 + +--part1_13d.2e68ed54_boundary +Content-Type: message/rfc822 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +From: <foo@example.net> +Subject: Hello +Date: Thu, 20 Oct 2022 14:40:36 +0000 + +Hello world + +--part1_13d.2e68ed54_boundary-- +''' diff --git a/feedbackloop/test/test_arf.py b/feedbackloop/test/test_arf.py new file mode 100644 index 0000000000000000000000000000000000000000..2ae1b063d8ca26d327d1d64528bfa47ec9647093 --- /dev/null +++ b/feedbackloop/test/test_arf.py @@ -0,0 +1,12 @@ +import unittest +from feedbackloop.arf import ARFMessage +from feedbackloop.test import TEST_MESSAGE + + +class ARFTest(unittest.TestCase): + + def test_parse_arf(self): + arf = ARFMessage(TEST_MESSAGE) + report = arf.get_feedback_report() + self.assertTrue(report) + self.assertEqual('1.2.3.4', report.get_source_ip()) diff --git a/feedbackloop/test/test_ingest.py b/feedbackloop/test/test_ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..8a46113c8bd80ceb398fcd6c561b5e136b765d8e --- /dev/null +++ b/feedbackloop/test/test_ingest.py @@ -0,0 +1,52 @@ +import mock +from flask_testing import TestCase +from feedbackloop.main import create_app, ingest +from feedbackloop.app import db +from feedbackloop.test import TEST_MESSAGE +from feedbackloop.model import FeedbackEntry + + +class IngestTest(TestCase): + + def create_app(self): + return create_app({ + 'TESTING': True, + 'IMAP_SERVER': 'localhost', + 'IMAP_USERNAME': 'user', + 'IMAP_PASSWORD': 'password', + }) + + def setUp(self): + db.create_all() + + def tearDown(self): + db.session.remove() + db.drop_all() + + def assertExitStatus(self, exit_status, fn, *args, **kwargs): + try: + fn(*args, **kwargs) + except SystemExit as exc: + self.assertEqual(exit_status, exc.code) + return + raise AssertionError('exit was not called') + + @mock.patch('imaplib.IMAP4_SSL') + def test_ingest_nothing(self, imap_mock): + imap_mock.return_value.uid.return_value = ('OK', [b'']) + + self.assertExitStatus(0, ingest, ['--limit', '10']) + + @mock.patch('imaplib.IMAP4_SSL') + def test_ingest_onemsg(self, imap_mock): + imap_mock.return_value.uid.side_effect = [ + ('OK', [b'42']), + ('OK', [[None, TEST_MESSAGE]]), + ('OK', []), + ] + + self.assertExitStatus(0, ingest, []) + + entry = FeedbackEntry.query.filter( + FeedbackEntry.sender=='foo@example.net').first() + self.assertTrue(entry) diff --git a/feedbackloop/test/test_mail_scanner.py b/feedbackloop/test/test_mail_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..485c2b58f4953fa1d0b5a59536875ee8c844e3d5 --- /dev/null +++ b/feedbackloop/test/test_mail_scanner.py @@ -0,0 +1,14 @@ +import unittest +from feedbackloop.parse import MailScanner +from feedbackloop.test import TEST_MESSAGE + + +class MailScannerTest(unittest.TestCase): + + def test_parse_message_ok(self): + ms = MailScanner('server', 'user', 'password') + entry = ms._parse_message(TEST_MESSAGE) + self.assertTrue(entry) + self.assertEqual('foo@example.net', entry.sender) + self.assertEqual('abusedesk@example.com', entry.reporter) + self.assertFalse(entry.is_list) diff --git a/setup.py b/setup.py index 4c3f1e3d79a4a7c4a0e28b5fe755cd5e368d5abb..5229475fa63b1c0bc797ca9ea3a45644e49a96c5 100644 --- a/setup.py +++ b/setup.py @@ -8,13 +8,8 @@ setup(name='feedbackloop', url='https://git.autistici.org/ai3/tools/feedback-loop', packages=find_packages(), install_requires=[ - 'Flask', 'Flask-Script', 'cheroot', 'sqlalchemy', 'Flask-SQLAlchemy', + 'Flask', 'cheroot', 'sqlalchemy', 'Flask-SQLAlchemy', ], zip_safe=False, include_package_data=True, - entry_points={ - 'console_scripts': [ - 'feedbackloop = feedbackloop.main:main', - ], - }, ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000000000000000000000000000000000..7117ea25e923dcae8335be6628061c880081c595 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py3 + +[testenv] +deps= + Flask-Testing + coverage + nose + mock +commands= + nosetests -vv --with-xunit --with-coverage --cover-package=feedbackloop --cover-erase --cover-html --cover-html-dir=htmlcov [] + coverage xml +