diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac3d97e..acf72ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## v0.20.0 (2025-01-11)
+
+### Feat
+
+- add basic support for "reports" feature
+
+### Fix
+
+- add `render_percent()` method for app handler
+- set global default sender to root@localhost
+
 ## v0.19.3 (2025-01-09)
 
 ### Fix
diff --git a/docs/api/wuttjamaican.reports.rst b/docs/api/wuttjamaican.reports.rst
new file mode 100644
index 0000000..0d79149
--- /dev/null
+++ b/docs/api/wuttjamaican.reports.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.reports``
+========================
+
+.. automodule:: wuttjamaican.reports
+   :members:
diff --git a/docs/glossary.rst b/docs/glossary.rst
index 3a16ea2..22f5ad5 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -247,6 +247,33 @@ Glossary
      portion of the :term:`app`.  Similar to a "plugin" concept; see
      :doc:`narr/providers/index`.
 
+   report
+     The concept of a report is intentionally vague, in the context of
+     WuttJamaican.  Basically it is something which can be "ran"
+     (usually with :term:`report params`) to generate a data set.  The
+     output can be viewed in the app UI, or it can be saved to file.
+
+     The base class is :class:`~wuttjamaican.reports.Report`.  See
+     also :term:`report handler`.
+
+   report handler
+     The :term:`handler` responsible for running :term:`reports
+     <report>`, for display in app UI or saved to file etc.
+
+     Base class is :class:`~wuttjamaican.reports.ReportHandler`.
+
+   report key
+     Unique key which identifies a particular :term:`report`.
+
+   report module
+     This refers to a Python module which contains :term:`report`
+     definitions.
+
+   report params
+     This refers to the input parameters used when running a
+     :term:`report`.  It is usually a simple mapping of key/value
+     pairs.
+
    settings table
      Table in the :term:`app database` which is used to store
      :term:`config settings<config setting>`.  See also
diff --git a/docs/index.rst b/docs/index.rst
index ef8acce..baa26ef 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -87,5 +87,6 @@ Contents
    api/wuttjamaican.install
    api/wuttjamaican.people
    api/wuttjamaican.progress
+   api/wuttjamaican.reports
    api/wuttjamaican.testing
    api/wuttjamaican.util
diff --git a/pyproject.toml b/pyproject.toml
index 5a88a56..b5310e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "WuttJamaican"
-version = "0.19.3"
+version = "0.20.0"
 description = "Base package for Wutta Framework"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py
index c7e0b37..f284f40 100644
--- a/src/wuttjamaican/app.py
+++ b/src/wuttjamaican/app.py
@@ -88,6 +88,7 @@ class AppHandler:
     default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
     default_install_handler_spec = 'wuttjamaican.install:InstallHandler'
     default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
+    default_report_handler_spec = 'wuttjamaican.reports:ReportHandler'
 
     def __init__(self, config):
         self.config = config
@@ -771,6 +772,22 @@ class AppHandler:
         """
         return simple_error(error)
 
+    def render_percent(self, value, decimals=2):
+        """
+        Return a human-friendly display string for the given
+        percentage value, e.g. ``23.45139`` becomes ``"23.45 %"``.
+
+        :param value: The value to be rendered.
+
+        :returns: Display string for the percentage value.
+        """
+        if value is None:
+            return ""
+        fmt = f'{{:0.{decimals}f}} %'
+        if value < 0:
+            return f'({fmt.format(-value)})'
+        return fmt.format(value)
+
     def render_quantity(self, value, empty_zero=False):
         """
         Return a human-friendly display string for the given quantity
@@ -879,6 +896,19 @@ class AppHandler:
             self.handlers['people'] = factory(self.config, **kwargs)
         return self.handlers['people']
 
+    def get_report_handler(self, **kwargs):
+        """
+        Get the configured :term:`report handler`.
+
+        :rtype: :class:`~wuttjamaican.reports.ReportHandler`
+        """
+        if 'reports' not in self.handlers:
+            spec = self.config.get(f'{self.appname}.reports.handler_spec',
+                                   default=self.default_report_handler_spec)
+            factory = self.load_object(spec)
+            self.handlers['reports'] = factory(self.config, **kwargs)
+        return self.handlers['reports']
+
     ##############################
     # convenience delegators
     ##############################
diff --git a/src/wuttjamaican/reports.py b/src/wuttjamaican/reports.py
new file mode 100644
index 0000000..6d5cb88
--- /dev/null
+++ b/src/wuttjamaican/reports.py
@@ -0,0 +1,228 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  WuttJamaican -- Base package for Wutta Framework
+#  Copyright © 2023-2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework is free software: you can redistribute it and/or modify it
+#  under the terms of the GNU General Public License as published by the Free
+#  Software Foundation, either version 3 of the License, or (at your option) any
+#  later version.
+#
+#  Wutta Framework is distributed in the hope that it will be useful, but
+#  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+#  more details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Report Utilities
+"""
+
+import importlib
+
+from wuttjamaican.app import GenericHandler
+
+
+class Report:
+    """
+    Base class for all :term:`reports <report>`.
+
+    .. attribute:: report_key
+
+       Each report must define a unique key, to identify it.
+
+    .. attribute:: report_title
+
+       This is the common display title for the report.
+    """
+    report_title = "Untitled Report"
+
+    def __init__(self, config):
+        self.config = config
+        self.app = config.get_app()
+
+    def add_params(self, schema):
+        """
+        Add field nodes to the given schema, defining all
+        :term:`report params`.
+
+        :param schema: :class:`~colander:colander.Schema` instance.
+
+        The schema is from Colander so nodes must be compatible with
+        that; for instance::
+
+           import colander
+
+           def add_params(self, schema):
+
+               schema.add(colander.SchemaNode(
+                   colander.Date(),
+                   name='start_date'))
+
+               schema.add(colander.SchemaNode(
+                   colander.Date(),
+                   name='end_date'))
+        """
+
+    def get_output_columns(self):
+        """
+        This should return a list of column definitions to be used
+        when displaying or persisting the data output.
+
+        Each entry can be a simple column name, or else a dict with
+        other options, e.g.::
+
+           def get_output_columns(self):
+               return [
+                   'foo',
+                   {'name': 'bar',
+                    'label': "BAR"},
+                   {'name': 'sales',
+                    'label': "Total Sales",
+                    'numeric': True,
+                    'formatter': self.app.render_currency},
+               ]
+
+        :returns: List of column definitions as described above.
+
+        The last entry shown above has all options currently
+        supported; here we explain those:
+
+        * ``name`` - True name for the column.
+
+        * ``label`` - Display label for the column.  If not specified,
+          one is derived from the ``name``.
+
+        * ``numeric`` - Boolean indicating the column data is numeric,
+          so should be right-aligned.
+
+        * ``formatter`` - Custom formatter / value rendering callable
+          for the column.  If set, this will be called with just one
+          arg (the value) for each data row.
+        """
+        raise NotImplementedError
+
+    def make_data(self, params, progress=None):
+        """
+        This must "run" the report and return the final data.
+
+        Note that this should *not* (usually) write the data to file,
+        its purpose is just to obtain the data.
+
+        The return value should usually be a dict, with no particular
+        structure required beyond that.  However it also can be a list
+        of data rows.
+
+        There is no default logic here; subclass must define.
+
+        :param params: Dict of :term:`report params`.
+
+        :param progress: Optional progress indicator factory.
+
+        :returns: Data dict, or list of rows.
+        """
+        raise NotImplementedError
+
+
+class ReportHandler(GenericHandler):
+    """
+    Base class and default implementation for the :term:`report
+    handler`.
+    """
+
+    def get_report_modules(self):
+        """
+        Returns a list of all known :term:`report modules <report
+        module>`.
+
+        This will discover all report modules exposed by the
+        :term:`app`, and/or its :term:`providers <provider>`.
+        """
+        if not hasattr(self, '_report_modules'):
+            self._report_modules = []
+            for provider in self.app.providers.values():
+                if hasattr(provider, 'report_modules'):
+                    modules = provider.report_modules
+                    if modules:
+                        if isinstance(modules, str):
+                            modules = [modules]
+                        for module in modules:
+                            module = importlib.import_module(module)
+                            self._report_modules.append(module)
+
+        return self._report_modules
+
+    def get_reports(self):
+        """
+        Returns a dict of all known :term:`reports <report>`, keyed by
+        :term:`report key`.
+
+        This calls :meth:`get_report_modules()` and for each module,
+        it discovers all the reports it contains.
+        """
+        if not hasattr(self, '_reports'):
+            self._reports = {}
+            for module in self.get_report_modules():
+                for name in dir(module):
+                    obj = getattr(module, name)
+                    if (isinstance(obj, type)
+                        and obj is not Report
+                        and issubclass(obj, Report)):
+                        self._reports[obj.report_key] = obj
+
+        return self._reports
+
+    def get_report(self, key, instance=True):
+        """
+        Fetch the :term:`report` class or instance for given key.
+
+        :param key: Identifying :term:`report key`.
+
+        :param instance: Whether to return the class, or an instance.
+           Default is ``True`` which means return the instance.
+
+        :returns: :class:`Report` class or instance, or ``None`` if
+           the report could not be found.
+        """
+        reports = self.get_reports()
+        if key in reports:
+            report = reports[key]
+            if instance:
+                report = report(self.config)
+            return report
+
+    def make_report_data(self, report, params=None, progress=None, **kwargs):
+        """
+        Run the given report and return the output data.
+
+        This calls :meth:`Report.make_data()` on the report, and
+        tweaks the output as needed for consistency.  The return value
+        should resemble this structure::
+
+           {
+               'output_title': "My Report",
+               'data': ...,
+           }
+
+        However that is the *minimum*; the dict may have other keys as
+        well.
+
+        :param report: :class:`Report` instance to run.
+
+        :param params: Dict of :term:`report params`.
+
+        :param progress: Optional progress indicator factory.
+
+        :returns: Data dict with structure shown above.
+        """
+        data = report.make_data(params or {}, progress=progress, **kwargs)
+        if not isinstance(data, dict):
+            data = {'data': data}
+        data.setdefault('output_title', report.report_title)
+        return data
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..1bbf4c1 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# nb. some tests require this to be a true package
diff --git a/tests/test_app.py b/tests/test_app.py
index 7c0f963..0176cfb 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -491,6 +491,22 @@ app_title = WuttaTest
             result = self.app.render_error(error)
         self.assertEqual(result, "RuntimeError")
 
+    def test_render_percent(self):
+
+        # null
+        self.assertEqual(self.app.render_percent(None), "")
+
+        # typical
+        self.assertEqual(self.app.render_percent(12.3419), '12.34 %')
+
+        # more decimal places
+        self.assertEqual(self.app.render_percent(12.3419, decimals=3), '12.342 %')
+        self.assertEqual(self.app.render_percent(12.3419, decimals=4), '12.3419 %')
+
+        # negative
+        self.assertEqual(self.app.render_percent(-12.3419), '(12.34 %)')
+        self.assertEqual(self.app.render_percent(-12.3419, decimals=3), '(12.342 %)')
+
     def test_render_quantity(self):
 
         # null
@@ -557,6 +573,12 @@ app_title = WuttaTest
         people = self.app.get_people_handler()
         self.assertIsInstance(people, PeopleHandler)
 
+    def test_get_report_handler(self):
+        from wuttjamaican.reports import ReportHandler
+
+        handler = self.app.get_report_handler()
+        self.assertIsInstance(handler, ReportHandler)
+
     def test_send_email(self):
         from wuttjamaican.email import EmailHandler
 
diff --git a/tests/test_email.py b/tests/test_email.py
index d21fa32..1776723 100644
--- a/tests/test_email.py
+++ b/tests/test_email.py
@@ -151,7 +151,7 @@ class TestEmailHandler(ConfigTestCase):
             self.assertEqual(len(modules), 1)
             self.assertIs(modules[0], mod)
 
-        # provider may specify modules as list
+        # provider may specify modules as string
         providers = {
             'wuttatest': MagicMock(email_modules='wuttjamaican.email'),
         }
diff --git a/tests/test_reports.py b/tests/test_reports.py
new file mode 100644
index 0000000..7b19385
--- /dev/null
+++ b/tests/test_reports.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch, MagicMock
+
+from wuttjamaican import reports as mod
+from wuttjamaican.testing import ConfigTestCase
+
+
+class MockFooReport(mod.Report):
+    report_key = 'mock_foo'
+    report_title = "MOCK Report"
+
+    def make_data(self, params, **kwargs):
+        return [
+            {'foo': 'bar'},
+        ]
+
+
+class TestReport(ConfigTestCase):
+
+    def test_get_output_columns(self):
+        report = mod.Report(self.config)
+        self.assertRaises(NotImplementedError, report.get_output_columns)
+
+    def test_make_data(self):
+        report = mod.Report(self.config)
+        self.assertRaises(NotImplementedError, report.make_data, {})
+
+
+class TestReportHandler(ConfigTestCase):
+
+    def make_handler(self):
+        return mod.ReportHandler(self.config)
+
+    def test_get_report_modules(self):
+
+        # no providers, no report modules
+        with patch.object(self.app, 'providers', new={}):
+            handler = self.make_handler()
+            self.assertEqual(handler.get_report_modules(), [])
+
+        # provider may specify modules as list
+        providers = {
+            'wuttatest': MagicMock(report_modules=['wuttjamaican.reports']),
+        }
+        with patch.object(self.app, 'providers', new=providers):
+            handler = self.make_handler()
+            modules = handler.get_report_modules()
+            self.assertEqual(len(modules), 1)
+            self.assertIs(modules[0], mod)
+
+        # provider may specify modules as string
+        providers = {
+            'wuttatest': MagicMock(report_modules='wuttjamaican.reports'),
+        }
+        with patch.object(self.app, 'providers', new=providers):
+            handler = self.make_handler()
+            modules = handler.get_report_modules()
+            self.assertEqual(len(modules), 1)
+            self.assertIs(modules[0], mod)
+
+    def test_get_reports(self):
+
+        # no providers, no reports
+        with patch.object(self.app, 'providers', new={}):
+            handler = self.make_handler()
+            self.assertEqual(handler.get_reports(), {})
+
+        # provider may define reports (via modules)
+        providers = {
+            'wuttatest': MagicMock(report_modules=['tests.test_reports']),
+        }
+        with patch.object(self.app, 'providers', new=providers):
+            handler = self.make_handler()
+            reports = handler.get_reports()
+            self.assertEqual(len(reports), 1)
+            self.assertIn('mock_foo', reports)
+
+    def test_get_report(self):
+        providers = {
+            'wuttatest': MagicMock(report_modules=['tests.test_reports']),
+        }
+
+        with patch.object(self.app, 'providers', new=providers):
+            handler = self.make_handler()
+
+            # as instance
+            report = handler.get_report('mock_foo')
+            self.assertIsInstance(report, mod.Report)
+            self.assertIsInstance(report, MockFooReport)
+
+            # as class
+            report = handler.get_report('mock_foo', instance=False)
+            self.assertTrue(issubclass(report, mod.Report))
+            self.assertIs(report, MockFooReport)
+
+    def test_make_report_data(self):
+        providers = {
+            'wuttatest': MagicMock(report_modules=['tests.test_reports']),
+        }
+
+        with patch.object(self.app, 'providers', new=providers):
+            handler = self.make_handler()
+            report = handler.get_report('mock_foo')
+
+            data = handler.make_report_data(report)
+            self.assertEqual(len(data), 2)
+            self.assertIn('output_title', data)
+            self.assertEqual(data['output_title'], "MOCK Report")
+            self.assertIn('data', data)
+            self.assertEqual(data['data'], [{'foo': 'bar'}])