Index: .gitignore
===================================================================
--- .gitignore	(revision ec51ffa204adc5efe2876168c375bea8fb63c50b)
+++ .gitignore	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -45,4 +45,9 @@
 # mail queue
 var/spool
+var/run/mail-queue-processor.lock
+
+# log files
+var/log/openworkouts.log
+var/log/queueprocessor.log
 
 # JS static external components and libraries
Index: ow/tasks/mail.py
===================================================================
--- ow/tasks/mail.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tasks/mail.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,49 @@
+"""
+Mail handling code. Any periodic task related to send/get emails is here
+"""
+
+import fcntl
+import logging
+from repoze.sendmail.queue import ConsoleApp
+
+log = logging.getLogger(__name__)
+
+
+def queue_processor(env):
+    """
+    Process the email queue, reusing repoze.sendmail default queue management
+    machinery.
+    """
+    # This mimics what is done by running the "qp" utility from
+    # repoze.sendmail, but uses our default .ini settings instead of using a
+    # separate ini file.
+
+    # This function expects some qp.* parameters in the provided settings,
+    # using the mail.* parameters as a fallback in case the former were not
+    # there.
+
+    # Before doing anything, check if a lock file exists. If it exists, exit
+    # without doing anything, as another queue processor process is running.
+    # See ticket #1389 for more information
+    settings = env['registry'].settings
+    lock_filename = settings.get('mail.queue_processor_lock')
+    lock_file = open(lock_filename, 'w')
+    try:
+        fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+    except IOError:
+        # Can't lock, probably another process is running, report to logging
+        # and exit
+        log.warning('Could not run the mail queue processing task, '
+                    'could not acquire lock (maybe another process is '
+                    'running?)')
+        return False
+
+    args = ['qp',
+            '--hostname', settings.get('qp.host', settings.get('mail.host')),
+            '--username', settings.get('qp.username',
+                                       settings.get('mail.username')),
+            '--password', settings.get('qp.password',
+                                       settings.get('mail.password')),
+            settings.get('mail.queue_path')]
+    app = ConsoleApp(args)
+    app.main()
Index: ow/tasks/manager.py
===================================================================
--- ow/tasks/manager.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tasks/manager.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,52 @@
+from pyramid.paster import bootstrap
+from pyramid.paster import setup_logging
+
+
+class TasksManager(object):
+
+    def __init__(self):
+        """
+        managers is a dict containing all the available tasks. The keys are
+        the name used to reference this tasks (the action parameter accepted
+        on runtime) while the values are the functions/methods responsible to
+        run the task.
+
+        Those functions/methods have to expect one parameter (the environment
+        we get from bootstraping pyramid, see the run() method to learn more)
+        """
+        self.managers = {}
+
+    def usage(self, script_name):
+        """
+        Prints command line usage.
+
+        The description of the different tasks is gathered from the docstring
+        of the methods that will be called to perform the task.
+        """
+        docs = [t + ': ' + self.managers[t].__doc__ for t in self.managers]
+        msg = """
+        Usage: %s /path/to/settings.ini [%s]
+
+          settings.ini has to be a PasteDeploy .ini file representing your
+          application configuration.
+
+          %s
+
+        """ % (script_name, '|'.join(self.managers), '\n\t  '.join(docs))
+        print(msg)
+
+    def add_task(self, name, method):
+        self.managers[name] = method
+
+    def remove_task(self, name):
+        if name in self.managers:
+            del self.managers[name]
+
+    def run(self, script_name, ini_file, action):
+        env = bootstrap(ini_file)
+        setup_logging(ini_file)
+        if action not in self.managers.keys():
+            self.usage(script_name)
+        else:
+            self.managers[action](env)
+        env['closer']()
Index: ow/tasks/run.py
===================================================================
--- ow/tasks/run.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tasks/run.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,25 @@
+import sys
+
+from ow.tasks.manager import TasksManager
+from ow.tasks.mail import queue_processor
+
+
+def command_line():
+    tasks_manager = TasksManager()
+
+    # "register" the tasks
+    tasks_manager.add_task('send_emails', queue_processor)
+
+    if len(sys.argv) != 3:
+        tasks_manager.usage(sys.argv[0])
+        sys.exit(1)
+
+    script_name = sys.argv[0]
+    ini_file = sys.argv[1]
+    action = sys.argv[2]
+
+    tasks_manager.run(script_name, ini_file, action)
+
+
+if __name__ == '__main__':
+    command_line()  # pragma no cover
Index: ow/tests/tasks/test_mail.py
===================================================================
--- ow/tests/tasks/test_mail.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tests/tasks/test_mail.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,89 @@
+import pytest
+from unittest.mock import patch, Mock
+
+from ow.tasks.mail import queue_processor
+
+
+class TestQueueProcessor(object):
+
+    @pytest.fixture
+    def settings(self):
+        settings = {
+            'qp.host': 'hostname',
+            'qp.username': 'username',
+            'qp.password': 'xxxxxxxx',
+            'mail.queue_path': '/some/path',
+            'mail.queue_processor_lock': '/tmp/queue-processor.lock'}
+        return settings
+
+    @pytest.fixture
+    def alt_settings(self):
+        alt_settings = {
+            'mail.host': 'mail-hostname',
+            'mail.username': 'mail-username',
+            'mail.password': 'yyyyyyyyyyyyy',
+            'mail.queue_path': '/some/path',
+            'mail.queue_processor_lock': '/tmp/queue-processor.lock'}
+        return alt_settings
+
+    def wrap_settings(self, settings):
+        registry = Mock(settings=settings)
+        return {'registry': registry}
+
+    def params(self, settings):
+        """
+        Return a list of arguments that ressembles what should be passed
+        to the ConsoleApp queue processor in the "real" code
+        """
+        args = ['qp',
+                '--hostname', settings.get('qp.host',
+                                           settings.get('mail.host')),
+                '--username', settings.get('qp.username',
+                                           settings.get('mail.username')),
+                '--password', settings.get('qp.password',
+                                           settings.get('mail.password')),
+                settings.get('mail.queue_path')]
+        return args
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.mail.ConsoleApp')
+    def test_queue_processor_settings(self, ca, lockf, settings):
+        queue_processor(self.wrap_settings(settings))
+        ca.assert_called_with(self.params(settings))
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.mail.ConsoleApp')
+    def test_queue_processor_alt_settings(self, ca, lockf, alt_settings):
+        queue_processor(self.wrap_settings(alt_settings))
+        ca.assert_called_with(self.params(alt_settings))
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.mail.log')
+    @patch('ow.tasks.mail.ConsoleApp')
+    def test_queue_processor_lock_ioerror(self, ca, log, lockf, settings):
+        """
+        A second call to the queue processor while a previous run is still
+        running will find a lock on the lock file, which means this call cannot
+        continue, so it is aborted and a warning message is logged to the
+        logging facilities
+        """
+        # Make lock raise an IOError, mimicing what happens when the lock has
+        # been acquired by another process
+        e = IOError('could not lock file!')
+        lockf.side_effect = e
+
+        ret = queue_processor(self.wrap_settings(settings))
+        # Exception raised, False returned
+        assert ret is False
+
+        # Calls: fcntl.lock has been called, then the IOError was raised
+        # and no more calls to the api have been made
+        assert lockf.called
+        assert not ca.called
+
+        # except for the log call, which was a warning
+        assert log.warning.called
Index: ow/tests/tasks/test_manager.py
===================================================================
--- ow/tests/tasks/test_manager.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tests/tasks/test_manager.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,115 @@
+from unittest.mock import Mock, patch
+from io import StringIO
+
+import pytest
+
+from ow.tasks.manager import TasksManager
+
+
+def faked_task(env):
+    """
+    This is a helper method, used in the tests below, mimicing a real
+    task we can add/remove to/from the task manager
+
+    >>> faked_task('env')
+    2
+    >>>
+    """
+    return 0 + 2
+
+
+class TestsTasksManager(object):
+
+    @pytest.fixture
+    def manager(self):
+        tm = TasksManager()
+        return tm
+
+    def test__init__(self, manager):
+        # once created, the manager has no tasks ready to be executed
+        assert manager.managers == {}
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_usage_empty(self, stdo, manager):
+        # without tasks
+        manager.usage('tasks-script')
+        assert 'tasks-script /path/to/settings.ini' in stdo.getvalue()
+        assert 'faked-task' not in stdo.getvalue()
+
+    @patch('sys.stdout', new_callable=StringIO)
+    def test_usage_with_tasks(self, stdo, manager):
+        # with tasks
+        manager.add_task('faked-task', faked_task)
+        manager.usage('tasks-script')
+        assert 'tasks-script /path/to/settings.ini' in stdo.getvalue()
+        assert 'faked-task' in stdo.getvalue()
+        assert faked_task.__doc__ in stdo.getvalue()
+
+    def test_add_task(self, manager):
+        assert manager.managers == {}
+        manager.add_task('faked-task', faked_task)
+        assert manager.managers == {'faked-task': faked_task}
+
+    def test_override_task(self, manager):
+        # if we call add_task on an existing task name, the task gets
+        # overriden
+        assert manager.managers == {}
+        manager.add_task('faked-task', faked_task)
+        assert manager.managers == {'faked-task': faked_task}
+        manager.add_task('faked-task', 'some-faked-not-object-task')
+        assert manager.managers == {'faked-task': 'some-faked-not-object-task'}
+
+    def test_remove_task(self, manager):
+        # try first to remove a non-existant task, nothing happens
+        assert manager.managers == {}
+        manager.remove_task('faked-task')
+        assert manager.managers == {}
+        manager.add_task('faked-task', faked_task)
+        assert manager.managers == {'faked-task': faked_task}
+        manager.remove_task('faked-task')
+        assert manager.managers == {}
+
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('ow.tasks.manager.setup_logging')
+    @patch('ow.tasks.manager.bootstrap')
+    def test_run_empty(self, b, sl, stdo, manager):
+        # we try to run a task that is not there, the usage method is called
+        env = {'closer': Mock()}
+        b.return_value = env
+        manager.run('tasks-script', 'development.ini', 'faked-task')
+        assert 'tasks-script /path/to/settings.ini' in stdo.getvalue()
+        assert 'faked-task' not in stdo.getvalue()
+        b.assert_called_with('development.ini')
+        sl.assert_called_with('development.ini')
+        assert env['closer'].called
+
+    @patch('sys.stdout', new_callable=StringIO)
+    @patch('ow.tasks.manager.setup_logging')
+    @patch('ow.tasks.manager.bootstrap')
+    def test_run_invalid_task(self, b, sl, stdo, manager):
+        # we try to run a task that is not there, the usage method is called
+        env = {'closer': Mock()}
+        b.return_value = env
+        manager.add_task('faked-task', faked_task)
+        manager.run('tasks-script', 'development.ini', 'wipe-it-all')
+        assert 'tasks-script /path/to/settings.ini' in stdo.getvalue()
+        assert 'faked-task' in stdo.getvalue()
+        assert faked_task.__doc__ in stdo.getvalue()
+        b.assert_called_with('development.ini')
+        sl.assert_called_with('development.ini')
+        assert env['closer'].called
+
+    @patch('ow.tasks.manager.setup_logging')
+    @patch('ow.tasks.manager.bootstrap')
+    def test_run_ok(self, b, sl, manager):
+        # we try to run a task that is not there, the usage method is called
+        env = {'closer': Mock()}
+        b.return_value = env
+        # for this test use this, easier to assert calls later
+        mocked_faked_task = Mock()
+        manager.add_task('faked-task', mocked_faked_task)
+        manager.run('tasks-script', 'development.ini', 'faked-task')
+        b.assert_called_with('development.ini')
+        sl.assert_called_with('development.ini')
+        mocked_faked_task.assert_called_with(env)
+        assert env['closer'].called
Index: ow/tests/tasks/test_run.py
===================================================================
--- ow/tests/tasks/test_run.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
+++ ow/tests/tasks/test_run.py	(revision b5d87e023fde98b4e5ab5dc7607439fd22424af2)
@@ -0,0 +1,62 @@
+from unittest.mock import Mock, patch
+
+import pytest
+
+from pyramid.testing import DummyRequest
+
+from ow.tasks.manager import TasksManager
+from ow.tasks.run import command_line
+
+
+@patch('ow.tasks.manager.setup_logging')
+@patch('ow.tasks.manager.bootstrap')
+@patch('ow.tasks.run.sys')
+@patch('ow.tasks.run.TasksManager')
+class TestCommandLine(object):
+    """
+    Tests covering the command_line method that allows users to execute the
+    tasks manager from a CLI.
+    """
+
+    @pytest.fixture
+    def registry(self):
+        """
+        Mock a pyramid registry
+        """
+        registry = Mock()
+        registry.settings = {}
+        return registry
+
+    @pytest.fixture
+    def env(self, registry):
+        env = {'request': DummyRequest(),
+               'root': {},
+               'closer': Mock(),
+               'registry': registry}
+        return env
+
+    @pytest.fixture
+    def manager(self):
+        tm = TasksManager()
+        tm.usage = Mock()
+        tm.usage.return_value = 'faked manager usage'
+        return tm
+
+    def test_wrong_params(self, tm, mysys, b, manager, env):
+        mysys.argv = ['script', 'one', 'two', 'three']
+        mysys.exit.side_effect = ValueError('sysexit')
+        tm.return_value = manager
+        b.return_value = env
+        with pytest.raises(ValueError):
+            command_line()
+        mysys.exit.assert_called_with(1)
+
+    @patch('ow.tasks.run.queue_processor')
+    def test_right_params_send_emails(
+            self, qp, tm, mysys, b, sl, manager, env):
+        mysys.argv = ['tasks-script', 'tests.ini', 'send_emails']
+        qp.return_value = None
+        tm.return_value = manager
+        b.return_value = env
+        command_line()
+        qp.assert_called_with(env)
