Add optional PostgreSQL storage backend (#667)

Adds a PostgreSQL output backend as a lighter-weight alternative to
Elasticsearch/OpenSearch, configured via a [postgresql] section
(host/port/user/password/database or a libpq connection_string). Tables
are created automatically on first run; a Grafana dashboard is included.

- psycopg is an optional extra (pip install parsedmarc[postgresql]); the
  import is guarded so `import parsedmarc` works without it, and
  PostgreSQLClient raises a clear install hint when constructed without
  the driver. Binary wheels aren't available for every platform.
- Schema captures the RFC 9990 / DMARCbis aggregate fields: np, testing,
  discovery_method, generator, xml_namespace, and per-result human_result
  on the DKIM/SPF auth-result tables.
- forensic -> failure naming throughout (table dmarc_failure_report,
  save_failure_report_to_postgresql, dashboard, docs) to match #659.
- Failure-report de-duplication mirrors the Elasticsearch backend exactly:
  arrival date + From + To + Subject (NULL-safe via IS NOT DISTINCT FROM;
  semantic JSONB equality). Aggregate and SMTP-TLS use ON CONFLICT.
- PostgreSQLClient.close() for clean CLI shutdown; comment documents why
  the two timestamp helpers must stay distinct (report dates are local,
  record/SMTP-TLS dates are UTC).
- CLI: config parse raises ConfigurationError on missing
  host/connection_string; wired into _init_output_clients + save loops.
- Tests in tests/test_postgres.py (helpers, mocked-DB save assertions,
  create_tables, connect/error wrapping, dedup, real-sample round trip)
  and tests/test_cli.py (config parse + end-to-end save wiring incl.
  AlreadySaved/PostgreSQLError handling). postgres.py at 99% line
  coverage; only _main's output-client-init retry path is left.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabio Scaccabarozzi
2026-05-21 09:17:49 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0a703172de
commit 327fcff2b9
8 changed files with 3900 additions and 0 deletions
+240
View File
@@ -2588,6 +2588,246 @@ class TestParseConfigS3(unittest.TestCase):
_parse_config(cp, _opts())
class TestParseConfigPostgreSQL(unittest.TestCase):
def test_postgresql_individual_params(self):
from parsedmarc.cli import _parse_config
cp = _config_with(
"postgresql",
{
"host": "db.example.com",
"port": "6543",
"user": "pmarc",
"password": "secret",
"database": "dmarc",
},
)
opts = _opts()
_parse_config(cp, opts)
self.assertEqual(opts.postgresql_host, "db.example.com")
self.assertEqual(opts.postgresql_port, 6543)
self.assertEqual(opts.postgresql_user, "pmarc")
self.assertEqual(opts.postgresql_password, "secret")
self.assertEqual(opts.postgresql_database, "dmarc")
def test_postgresql_connection_string_takes_precedence(self):
"""connection_string is read and host parsing is skipped."""
from parsedmarc.cli import _parse_config
cp = _config_with(
"postgresql",
{
"connection_string": "postgresql://u:p@h/db",
"host": "ignored.example.com",
},
)
opts = _opts()
_parse_config(cp, opts)
self.assertEqual(opts.postgresql_connection_string, "postgresql://u:p@h/db")
# The host branch is skipped entirely when a connection_string is set.
self.assertFalse(hasattr(opts, "postgresql_host"))
def test_postgresql_missing_host_and_dsn_raises(self):
from parsedmarc.cli import ConfigurationError, _parse_config
cp = _config_with("postgresql", {"port": "5432"})
with self.assertRaises(ConfigurationError) as ctx:
_parse_config(cp, _opts())
self.assertIn("postgresql", str(ctx.exception))
class TestPostgreSQLCliWiring(unittest.TestCase):
"""End-to-end: a [postgresql] config reaches PostgreSQLClient + create_tables.
Regression guard so the config parse, the Namespace defaults, and the
_init_output_clients wiring can't drift apart.
"""
def test_postgresql_config_constructs_client_and_creates_tables(self):
config = """[general]
save_aggregate = true
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
[postgresql]
host = db.example.com
port = 6543
user = pmarc
password = secret
database = dmarc
"""
with tempfile.NamedTemporaryFile(
"w", suffix=".ini", delete=False
) as config_file:
config_file.write(config)
config_path = config_file.name
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
with (
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
patch(
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
return_value={
"aggregate_reports": [],
"failure_reports": [],
"smtp_tls_reports": [],
},
),
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
):
parsedmarc.cli._main()
mock_client_cls.assert_called_once()
kwargs = mock_client_cls.call_args.kwargs
self.assertEqual(kwargs.get("host"), "db.example.com")
self.assertEqual(kwargs.get("port"), 6543)
self.assertEqual(kwargs.get("user"), "pmarc")
self.assertEqual(kwargs.get("database"), "dmarc")
mock_client_cls.return_value.create_tables.assert_called_once()
def test_postgresql_aggregate_report_is_saved(self):
"""An aggregate report reaches the client's save method via the loop."""
config = """[general]
save_aggregate = true
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
[postgresql]
host = db.example.com
"""
with tempfile.NamedTemporaryFile(
"w", suffix=".ini", delete=False
) as config_file:
config_file.write(config)
config_path = config_file.name
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
report = {"policy_published": {"domain": "example.com"}, "records": []}
with (
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
patch(
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
return_value={
"aggregate_reports": [report],
"failure_reports": [],
"smtp_tls_reports": [],
},
),
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
):
parsedmarc.cli._main()
pg_client = mock_client_cls.return_value
pg_client.save_aggregate_report_to_postgresql.assert_called_once_with(report)
def _run_main(self, reports, save_side_effect=None):
"""Run _main with all save flags on and PostgreSQLClient mocked.
Returns the mocked client instance for assertions. *save_side_effect*,
if given, is applied to every save_* method so error-handling branches
can be exercised.
"""
config = """[general]
save_aggregate = true
save_failure = true
save_smtp_tls = true
silent = true
[imap]
host = imap.example.com
user = test-user
password = test-password
[postgresql]
host = db.example.com
"""
with tempfile.NamedTemporaryFile(
"w", suffix=".ini", delete=False
) as config_file:
config_file.write(config)
config_path = config_file.name
self.addCleanup(lambda: os.path.exists(config_path) and os.remove(config_path))
with (
patch("parsedmarc.cli.postgres.PostgreSQLClient") as mock_client_cls,
patch(
"parsedmarc.cli.get_dmarc_reports_from_mailbox",
return_value=reports,
),
patch("parsedmarc.cli.IMAPConnection", return_value=object()),
patch.object(sys, "argv", ["parsedmarc", "-c", config_path]),
):
client = mock_client_cls.return_value
if save_side_effect is not None:
for m in (
"save_aggregate_report_to_postgresql",
"save_failure_report_to_postgresql",
"save_smtp_tls_report_to_postgresql",
):
getattr(client, m).side_effect = save_side_effect
parsedmarc.cli._main()
return client
def test_postgresql_all_report_types_saved(self):
"""Failure and SMTP-TLS reports also reach their save methods."""
agg = {"policy_published": {"domain": "example.com"}, "records": []}
fail = {"reported_domain": "example.com", "parsed_sample": {}}
tls = {"organization_name": "Org", "policies": [{"policy_domain": "d"}]}
client = self._run_main(
{
"aggregate_reports": [agg],
"failure_reports": [fail],
"smtp_tls_reports": [tls],
}
)
client.save_aggregate_report_to_postgresql.assert_called_once_with(agg)
client.save_failure_report_to_postgresql.assert_called_once_with(fail)
client.save_smtp_tls_report_to_postgresql.assert_called_once_with(tls)
def test_postgresql_already_saved_is_warned_not_fatal(self):
"""AlreadySaved from any save is swallowed (logged), not propagated."""
from parsedmarc import postgres
agg = {"policy_published": {"domain": "example.com"}, "records": []}
fail = {"reported_domain": "example.com", "parsed_sample": {}}
tls = {"organization_name": "Org", "policies": []}
# Should not raise despite every save raising AlreadySaved.
self._run_main(
{
"aggregate_reports": [agg],
"failure_reports": [fail],
"smtp_tls_reports": [tls],
},
save_side_effect=postgres.AlreadySaved("dup"),
)
def test_postgresql_error_is_logged_not_fatal(self):
"""PostgreSQLError from any save is logged, not propagated."""
from parsedmarc import postgres
agg = {"policy_published": {"domain": "example.com"}, "records": []}
fail = {"reported_domain": "example.com", "parsed_sample": {}}
tls = {"organization_name": "Org", "policies": []}
self._run_main(
{
"aggregate_reports": [agg],
"failure_reports": [fail],
"smtp_tls_reports": [tls],
},
save_side_effect=postgres.PostgreSQLError("boom"),
)
class TestParseConfigSyslog(unittest.TestCase):
def test_syslog_complete(self):
from parsedmarc.cli import _parse_config