Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name: CI and release pipeline
on:
pull_request:
push:
branches: ["main", "develop"]
tags: ["*"]
branches: [ "main", "develop" ]
tags: [ "*" ]

jobs:
static-analysis:
Expand All @@ -19,11 +19,12 @@ jobs:
- name: Check static typing
run: poetry run mypy dmarc_metrics_exporter
steps:
- uses: actions/checkout@v4
- run: git fetch --no-tags --prune --depth=1 origin +refs/heads/main:refs/remotes/origin/main
- uses: actions/checkout@v6
- run: git fetch --no-tags --prune --depth=1 origin
+refs/heads/main:refs/remotes/origin/main

- name: Set up Python
uses: actions/setup-python@v5.0.0
uses: actions/setup-python@v6.2.0
with:
python-version: "3.13"

Expand All @@ -45,13 +46,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5.0.0
uses: actions/setup-python@v6.2.0
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -62,27 +63,28 @@ jobs:
- uses: actions/cache@v5.0.0
with:
path: .venv
key: ${{ runner.os }}-py${{ matrix.python-version }}-venv-${{ hashFiles('pyproject.toml') }}
key: ${{ runner.os }}-py${{ matrix.python-version }}-venv-${{
hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-py${{ matrix.python-version }}-venv-
- uses: ./.github/actions/setup-project
- name: Start required services (Greenmail)
run: docker compose up -d
- name: Test and measure coverage with pytest
run: poetry run pytest --verbose --cov=dmarc_metrics_exporter --cov-report=xml
- uses: codecov/codecov-action@v4
- uses: codecov/codecov-action@v6
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

release:
runs-on: ubuntu-latest
needs: [static-analysis, test]
needs: [ static-analysis, test ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v5.0.0
uses: actions/setup-python@v6.2.0
with:
python-version: "3.13"

Expand Down Expand Up @@ -110,7 +112,9 @@ jobs:

- name: Extract changelog
id: changelog
run: sed -E -n '/^\[${{ steps.version.outputs.version }}\]/,/^\[[0-9\.]+\]/{/^\[[0-9\.]+\]|^-+$/!p;}' CHANGELOG.rst > release-body.rst
run: sed -E -n '/^\[${{ steps.version.outputs.version
}}\]/,/^\[[0-9\.]+\]/{/^\[[0-9\.]+\]|^-+$/!p;}' CHANGELOG.rst >
release-body.rst

- uses: docker://pandoc/core:3
with:
Expand All @@ -132,7 +136,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

docker-image:
needs: [release]
needs: [ release ]
uses: "jgosmann/dmarc-metrics-exporter/.github/workflows/docker-publish.yml@main"
with:
version: ${{ github.ref }}
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ Added
^^^^^

* Support for DMARC 2.0 reports from [draft-ietf-dmarc-aggregate-reporting-32](https://datatracker.ietf.org/doc/draft-ietf-dmarc-aggregate-reporting/32/).
* Support for Python 3.14.

Changed
^^^^^^^

* Drop support for Python 3.9.


[1.2.0] - 2024-10-15
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM --platform=linux/amd64 python:3.13-slim AS builder
FROM --platform=linux/amd64 python:3.14-slim AS builder
ARG version

# install python package in venv
RUN python3 -m venv venv && \
venv/bin/pip3 --disable-pip-version-check install dmarc-metrics-exporter==${version}

FROM python:3.13-alpine AS runner
FROM python:3.14-alpine AS runner

# adduser and directories
RUN addgroup --system --gid 1000 dmarc-metrics && \
Expand Down
75 changes: 30 additions & 45 deletions dmarc_metrics_exporter/metrics_persister.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,20 @@
import json
from pathlib import Path
from typing import Any, List, Tuple

from dataclasses_serialization.json import JSONSerializer
from pydantic import BaseModel, TypeAdapter

from dmarc_metrics_exporter.dmarc_event import Disposition, Meta
from dmarc_metrics_exporter.dmarc_event import Meta

from .dmarc_metrics import DmarcMetrics, DmarcMetricsCollection, InvalidMeta

_Meta = TypeAdapter(Meta)
_DmarcMetrics = TypeAdapter(DmarcMetrics)
_InvalidMeta = TypeAdapter(InvalidMeta)

# false positive, pylint: disable=no-value-for-parameter
@JSONSerializer.register_serializer(Disposition)
def disposition_serializer(disposition: Disposition) -> str:
return disposition.value


@JSONSerializer.register_serializer(DmarcMetricsCollection)
def dmarc_metrics_collection_serializer(
metrics: DmarcMetricsCollection,
) -> List[Tuple[Any, Any]]:
return JSONSerializer.serialize(
{
"metrics": [list(item) for item in metrics.items()],
"invalid_reports": [list(item) for item in metrics.invalid_reports.items()],
}
)


@JSONSerializer.register_deserializer(Disposition)
def disposition_deserializer(_cls, obj: str) -> Disposition:
return Disposition(obj)


@JSONSerializer.register_deserializer(DmarcMetricsCollection)
def dmarc_metrics_collection_deserializer(_cls, obj) -> DmarcMetricsCollection:
is_old_format = isinstance(obj, list)
if is_old_format:
obj = {"metrics": obj}
return DmarcMetricsCollection(
dict(
(
JSONSerializer.deserialize(Meta, meta),
JSONSerializer.deserialize(DmarcMetrics, metrics),
)
for meta, metrics in obj.get("metrics", tuple())
),
dict(
(JSONSerializer.deserialize(InvalidMeta, meta), count)
for meta, count in obj.get("invalid_reports", tuple())
),
)
class _SerializationModel(BaseModel):
metrics: list[tuple[Meta, DmarcMetrics]]
invalid_reports: list[tuple[InvalidMeta, int]] = []


class MetricsPersister:
Expand All @@ -59,10 +24,30 @@ def __init__(self, path: Path):
def load(self) -> DmarcMetricsCollection:
try:
with open(self.path, "r", encoding="utf-8") as f:
return JSONSerializer.deserialize(DmarcMetricsCollection, json.load(f))
obj = json.load(f)
is_old_format = isinstance(obj, list)
if is_old_format:
obj = {"metrics": obj}
model = _SerializationModel(**obj)
return DmarcMetricsCollection(
metrics={
_Meta.validate_python(meta): _DmarcMetrics.validate_python(
metrics
)
for meta, metrics in model.metrics
},
invalid_reports={
_InvalidMeta.validate_python(meta): count
for meta, count in model.invalid_reports
},
)
except FileNotFoundError:
return DmarcMetricsCollection()

def save(self, metrics: DmarcMetricsCollection):
model = _SerializationModel(
metrics=[tuple(item) for item in metrics.items()],
invalid_reports=[(k, v) for k, v in metrics.invalid_reports.items()],
)
with open(self.path, "w", encoding="utf-8") as f:
json.dump(JSONSerializer.serialize(metrics), f)
f.write(model.model_dump_json())
54 changes: 54 additions & 0 deletions dmarc_metrics_exporter/tests/test_metrics_persister.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,60 @@ def test_loads_old_format(tmp_path):
)


def test_loads_old_format2(tmp_path):
metrics_db = tmp_path / "metrics.db"
metrics_db.write_text(
"""{
"metrics": [
[
{
"reporter": "google.com",
"from_domain": "mydomain.de",
"dkim_domain": "dkim-domain.org",
"spf_domain": "spf-domain.org"
}, {
"total_count": 42,
"disposition_counts": {"quarantine": 4},
"dmarc_compliant_count": 24,
"dkim_pass_count": 10,
"spf_pass_count": 8,
"dkim_aligned_count": 5,
"spf_aligned_count": 4
}
]
],
"invalid_reports": [
[
{"from_email": "invalid <invalid@example.invalid>"},
1
]
]
}"""
)
persister = MetricsPersister(metrics_db)
assert persister.load() == DmarcMetricsCollection(
metrics={
Meta(
reporter="google.com",
from_domain="mydomain.de",
dkim_domain="dkim-domain.org",
spf_domain="spf-domain.org",
): DmarcMetrics(
total_count=42,
disposition_counts={Disposition.QUARANTINE: 4},
dmarc_compliant_count=24,
dkim_aligned_count=5,
dkim_pass_count=10,
spf_aligned_count=4,
spf_pass_count=8,
)
},
invalid_reports={
InvalidMeta(from_email="invalid <invalid@example.invalid>"): 1,
},
)


def test_returns_newly_initialized_metrics_if_db_is_non_existent(tmp_path):
metrics_db = tmp_path / "metrics.db"
persister = MetricsPersister(metrics_db)
Expand Down
Loading