Compare commits

...

12 Commits

7 changed files with 343 additions and 43 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.gitignore
.venv
__pycache__
*.pyc
dmarc-to-discord.service
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.14-alpine
# uv configuration: compile bytecode for faster startup, copy (not link)
# packages out of the build cache, and keep the managed venv at /app/.venv.
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
# Install dependencies first, using only the lockfile + manifest so this
# layer is cached until the dependency set actually changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Now bring in the application and install it into the venv.
COPY pyproject.toml uv.lock README.md dmarc_to_discord.py ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Run unprivileged.
RUN adduser -S -H -u 10001 appuser
USER appuser
# Bind to all interfaces inside the container
ENV LISTEN_HOST=0.0.0.0 \
LISTEN_PORT=8080
EXPOSE 8080
ENTRYPOINT ["dmarc-to-discord"]
+80
View File
@@ -0,0 +1,80 @@
# dmarc-to-discord
A tiny HTTP relay that turns [parsedmarc](https://github.com/domainaware/parsedmarc) aggregate-report webhooks into nicely formatted Discord embeds.
parsedmarc parses DMARC aggregate reports from your inbox and POSTs each one as JSON to a webhook URL of your choosing. This service is that webhook: it listens for parsedmarc's POSTs, builds one metadata embed plus one embed per record (source IP, alignment, disposition, auth results, override reasons), and forwards them to a Discord channel.
## How it looks
Each report produces:
- **1 metadata embed** — reporter, report ID, timespan, published policy (`p`, `sp`, `adkim`, `aspf`, `pct`, `fo`), and a pass/total summary.
- **1 embed per record** — source IP/country/rDNS/ASN, message count, disposition, header-from, DMARC/SPF/DKIM alignment, policy-evaluated SPF/DKIM, raw auth results, and any policy override reasons.
Embeds are colored green (DMARC aligned), red (quarantine/reject), or orange (anything else). Discord allows max 10 embeds per message, so larger reports are split across multiple messages.
## Configuration
Environment variables:
| Variable | Default | Description |
| --- | --- | --- |
| `DISCORD_WEBHOOK_URL` | *(required)* | Discord channel webhook URL |
| `WEBHOOK_SECRET` | *(unset)* | if set, POSTs must arrive at `/<secret>`; mismatches get `404`. Leave unset to disable the guard |
| `LISTEN_HOST` | `127.0.0.1` | bind address |
| `LISTEN_PORT` | `8080` | bind port |
When the relay is reachable beyond a trusted private network, set `WEBHOOK_SECRET` to a long random string (e.g. `openssl rand -hex 32`) and put it in the parsedmarc URL — it's a shared secret, so keep TLS in front of it. The secret is compared in constant time, and the health endpoints (`/`, `/health`, `/healthz`) stay open regardless.
## Running
This project uses [uv](https://docs.astral.sh/uv/) for Python and dependency management.
```sh
uv run dmarc-to-discord
# or, for local hacking:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... uv run dmarc-to-discord
```
`GET /`, `/health`, and `/healthz` return `200 ok` for liveness checks.
## Wiring up parsedmarc
In `parsedmarc.ini`:
```ini
[webhook]
aggregate_url = http://127.0.0.1:8080/
# with WEBHOOK_SECRET set:
# aggregate_url = https://dmarc.example.com/<secret>
```
(parsedmarc also supports `forensic_url` and `smtp_tls_url`; this relay currently only handles the aggregate-report schema.)
## Running with Docker
The included `Dockerfile` builds the service with uv. The image:
- exposes port `8080` and binds to `0.0.0.0` inside the container (override with `LISTEN_PORT` / `LISTEN_HOST`);
- requires the `DISCORD_WEBHOOK_URL` environment variable;
- honors `WEBHOOK_SECRET` for the path-based auth guard (recommended when public);
- runs as an unprivileged user;
- serves `/healthz` (returns `200 ok`) for container health checks.
```sh
docker build -t dmarc-to-discord .
docker run -p 8080:8080 \
-e DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... \
-e WEBHOOK_SECRET=$(openssl rand -hex 32) \
dmarc-to-discord
```
Any platform that builds from a Dockerfile can deploy it: point it at this
repository, set `DISCORD_WEBHOOK_URL`, expose port `8080`, and use `/healthz`
as the health check.
## Notes
- The server speaks plain HTTP. Terminate TLS in front of it, or keep it on a private network with parsedmarc.
- Discord 429s are honored via `retry_after`; there's a 0.5 s gap between messages to stay friendly to the rate limiter.
- No persistence — if Discord is down when a report arrives, the report is dropped (parsedmarc will not retry).
-29
View File
@@ -1,29 +0,0 @@
[Unit]
Description=parsedmarc -> Discord webhook relay
After=network-online.target
Wants=network-online.target
# parsedmarc should start after the relay so early POSTs don't get refused
Before=parsedmarc.service
[Service]
Type=simple
EnvironmentFile=/etc/dmarc-to-discord.env
ExecStart=/usr/local/bin/dmarc_to_discord.py
Restart=on-failure
RestartSec=5
# Sandboxing — no filesystem writes needed at all
DynamicUser=true
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
+109 -14
View File
@@ -8,12 +8,14 @@ it to Discord embeds and forward.
Env vars: Env vars:
DISCORD_WEBHOOK_URL required DISCORD_WEBHOOK_URL required
WEBHOOK_SECRET optional; if set, POSTs must arrive at /<secret>
LISTEN_HOST default 127.0.0.1 LISTEN_HOST default 127.0.0.1
LISTEN_PORT default 8080 LISTEN_PORT default 8080
""" """
from __future__ import annotations from __future__ import annotations
import hmac
import json import json
import logging import logging
import os import os
@@ -21,10 +23,12 @@ import sys
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from urllib.parse import urlsplit
import requests import requests
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "") DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1") LISTEN_HOST = os.environ.get("LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080")) LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))
@@ -38,19 +42,23 @@ def truncate(s: str, n: int = MAX_FIELD_VALUE_LEN) -> str:
return s if len(s) <= n else s[: n - 3] + "..." return s if len(s) <= n else s[: n - 3] + "..."
def result_icon(r): return {"pass": "", "fail": "", "softfail": "⚠️", def result_icon(r): return {"pass": "", "fail": "", "softfail": "⚠️", "neutral": "",
"neutral": "", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "") "none": "", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "")
def bool_icon(b): return "" if b else "" def bool_icon(b): return "" if b is True else "" if b is False else ""
def record_color(record): def record_color(record):
if record.get("alignment", {}).get("dmarc"): # Drive color by DMARC alignment, not by disposition: on a p=none policy every
# failure has disposition=none, and we don't want real spoofing to look the same
# as benign forwarder noise.
dmarc = (record.get("alignment") or {}).get("dmarc")
if dmarc is True:
return COLOR_PASS return COLOR_PASS
if record.get("policy_evaluated", {}).get("disposition") in ("quarantine", "reject"): if dmarc is False:
return COLOR_FAIL return COLOR_FAIL
return COLOR_PARTIAL return COLOR_PARTIAL # alignment missing → unknown
def build_metadata_embed(report): def build_metadata_embed(report):
@@ -60,9 +68,13 @@ def build_metadata_embed(report):
total = sum(r.get("count", 0) for r in records) total = sum(r.get("count", 0) for r in records)
passing = sum(r.get("count", 0) for r in records if r.get("alignment", {}).get("dmarc")) passing = sum(r.get("count", 0) for r in records if r.get("alignment", {}).get("dmarc"))
reporter_lines = [meta.get("org_name", "unknown"), meta.get("org_email", "")]
if extra := meta.get("org_extra_contact_info"):
reporter_lines.append(extra)
fields = [ fields = [
{"name": "Reporter", {"name": "Reporter",
"value": f"{meta.get('org_name', 'unknown')}\n{meta.get('org_email', '')}", "inline": True}, "value": "\n".join(filter(None, reporter_lines)), "inline": True},
{"name": "Report ID", "value": f"`{meta.get('report_id', 'unknown')}`", "inline": True}, {"name": "Report ID", "value": f"`{meta.get('report_id', 'unknown')}`", "inline": True},
{"name": "Timespan (UTC)", {"name": "Timespan (UTC)",
"value": f"{meta.get('begin_date', '?')}\n{meta.get('end_date', '?')}", "inline": False}, "value": f"{meta.get('begin_date', '?')}\n{meta.get('end_date', '?')}", "inline": False},
@@ -70,33 +82,97 @@ def build_metadata_embed(report):
"value": (f"**Domain:** `{policy.get('domain', '?')}`\n" "value": (f"**Domain:** `{policy.get('domain', '?')}`\n"
f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\n" f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\n"
f"**adkim / aspf:** `{policy.get('adkim', '?')}` / `{policy.get('aspf', '?')}`\n" f"**adkim / aspf:** `{policy.get('adkim', '?')}` / `{policy.get('aspf', '?')}`\n"
f"**pct:** `{policy.get('pct', '?')}` • **fo:** `{policy.get('fo', '?')}`"), f"**pct:** `{policy.get('pct') or '100'}` • **fo:** `{policy.get('fo', '?')}`"),
"inline": False}, "inline": False},
{"name": "Summary", {"name": "Summary",
"value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}", "value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}",
"inline": False}, "inline": False},
] ]
if errors := (meta.get("errors") or []): errors = meta.get("errors") or []
if errors:
fields.append({"name": "Errors", fields.append({"name": "Errors",
"value": truncate("\n".join(f"{e}" for e in errors)), "inline": False}) "value": truncate("\n".join(f"{e}" for e in errors)), "inline": False})
return {"title": f"DMARC Aggregate Report — {policy.get('domain', 'unknown')}", return {"title": f"DMARC Aggregate Report — {policy.get('domain', 'unknown')}",
"color": COLOR_INFO, "fields": fields} "color": COLOR_PARTIAL if errors else COLOR_INFO, "fields": fields}
def build_record_embed(record, idx, total): def diagnose_record(record):
"""One-line explanation of why DMARC passed or failed for this record."""
align = record.get("alignment") or {}
auth = record.get("auth_results") or {}
ids = record.get("identifiers") or {}
header_from = ids.get("header_from") or "?"
spf_results = auth.get("spf") or []
dkim_results = auth.get("dkim") or []
dmarc = align.get("dmarc")
if dmarc is None:
return None # parsedmarc didn't supply alignment; don't fabricate a verdict
if dmarc:
via = [name for name, ok in (("SPF", align.get("spf")), ("DKIM", align.get("dkim"))) if ok]
return "✅ **DMARC pass** — aligned via " + (" + ".join(via) if via else "?")
parts = []
spf_pass = next((r for r in spf_results if (r.get("result") or "").lower() == "pass"), None)
if not spf_results:
parts.append("SPF not evaluated")
elif spf_pass:
parts.append(f"SPF passed on `{spf_pass.get('domain', '?')}` (not aligned with `{header_from}`)")
else:
worst = spf_results[0]
parts.append(f"SPF `{worst.get('result', '?')}` on `{worst.get('domain', '?')}`")
dkim_pass = next((r for r in dkim_results if (r.get("result") or "").lower() == "pass"), None)
if not dkim_results:
parts.append("DKIM not signed")
elif dkim_pass:
parts.append(f"DKIM passed on `{dkim_pass.get('domain', '?')}` (not aligned with `{header_from}`)")
else:
failed = ", ".join(f"`{r.get('domain', '?')}`/`{r.get('selector', '?')}`→`{r.get('result', '?')}`"
for r in dkim_results)
parts.append(f"DKIM failed ({failed})")
return "❌ **DMARC fail** — " + "; ".join(parts)
def build_record_embed(record, idx, total, policy_domain=None):
src, align = record.get("source", {}), record.get("alignment", {}) src, align = record.get("source", {}), record.get("alignment", {})
pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {}) pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {})
rdns = src.get("reverse_dns") or "" rdns = src.get("reverse_dns") or ""
asn = src.get("asn") asn = src.get("asn")
as_str = f"AS{asn} ({src.get('as_name', '?')})" if asn else "" as_str = f"AS{asn} ({src.get('as_name', '?')})" if asn else ""
if asn and (as_domain := src.get("as_domain")):
as_str += f" — `{as_domain}`"
sender_bits = []
if name := src.get("name"):
sender_bits.append(f"**{name}**")
if base := src.get("base_domain"):
sender_bits.append(f"`{base}`")
sender_line = " ".join(sender_bits) + "\n" if sender_bits else ""
header_from = ids.get("header_from") or "?"
hf_note = ""
if policy_domain and header_from != "?" and header_from.lower() != policy_domain.lower():
hf_note = " *(subdomain — `sp` applies)*"
from_lines = [f"**Header From:** `{header_from}`{hf_note}",
f"**Envelope From:** `{ids.get('envelope_from') or ''}`"]
if env_to := ids.get("envelope_to"):
from_lines.append(f"**Envelope To:** `{env_to}`")
fields = [ fields = [
{"name": "Source", {"name": "Source",
"value": f"**IP:** `{src.get('ip_address', '?')}` ({src.get('country', '??')})\n" "value": sender_line +
f"**IP:** `{src.get('ip_address', '?')}` ({src.get('country', '??')})\n"
f"**rDNS:** `{rdns}`\n**ASN:** {as_str}", "inline": False}, f"**rDNS:** `{rdns}`\n**ASN:** {as_str}", "inline": False},
]
if verdict := diagnose_record(record):
fields.append({"name": "Verdict", "value": truncate(verdict), "inline": False})
fields += [
{"name": "Messages", "value": f"**{record.get('count', 0)}**", "inline": True}, {"name": "Messages", "value": f"**{record.get('count', 0)}**", "inline": True},
{"name": "Disposition", "value": f"`{pol.get('disposition', '?')}`", "inline": True}, {"name": "Disposition", "value": f"`{pol.get('disposition', '?')}`", "inline": True},
{"name": "Header From", "value": f"`{ids.get('header_from', '?')}`", "inline": True}, {"name": "Identifiers", "value": "\n".join(from_lines), "inline": False},
{"name": "Alignment", {"name": "Alignment",
"value": f"{bool_icon(align.get('dmarc'))} DMARC\n" "value": f"{bool_icon(align.get('dmarc'))} DMARC\n"
f"{bool_icon(align.get('spf'))} SPF\n" f"{bool_icon(align.get('spf'))} SPF\n"
@@ -124,8 +200,9 @@ def build_record_embed(record, idx, total):
def send_to_discord(report): def send_to_discord(report):
embeds = [build_metadata_embed(report)] embeds = [build_metadata_embed(report)]
records = report.get("records", []) records = report.get("records", [])
policy_domain = (report.get("policy_published") or {}).get("domain")
for i, rec in enumerate(records, start=1): for i, rec in enumerate(records, start=1):
embeds.append(build_record_embed(rec, i, len(records))) embeds.append(build_record_embed(rec, i, len(records), policy_domain))
for i in range(0, len(embeds), MAX_EMBEDS_PER_MESSAGE): for i in range(0, len(embeds), MAX_EMBEDS_PER_MESSAGE):
payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"} payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"}
@@ -144,7 +221,21 @@ def send_to_discord(report):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _authorized(self) -> bool:
# When no secret is configured the guard is disabled (local convenience).
# Otherwise the request path must equal "/<secret>"; parsedmarc preserves
# the full aggregate_url, so the secret rides along in the path. Constant-
# time compare avoids leaking the secret via response timing.
if not WEBHOOK_SECRET:
return True
path = urlsplit(self.path).path
return hmac.compare_digest(path, "/" + WEBHOOK_SECRET)
def do_POST(self): def do_POST(self):
if not self._authorized():
# 404 (not 401) so the endpoint doesn't advertise that it exists.
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) body = self.rfile.read(length)
try: try:
@@ -182,6 +273,10 @@ def main():
format="%(asctime)s %(levelname)s %(message)s") format="%(asctime)s %(levelname)s %(message)s")
srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT) logging.info("listening on %s:%d", LISTEN_HOST, LISTEN_PORT)
if WEBHOOK_SECRET:
logging.info("secret guard enabled; POST to /<WEBHOOK_SECRET>")
else:
logging.warning("WEBHOOK_SECRET unset; accepting unauthenticated POSTs on any path")
srv.serve_forever() srv.serve_forever()
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "dmarc-to-discord"
version = "0.1.0"
description = "Relay parsedmarc aggregate-report webhooks to a Discord webhook"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"requests>=2.31",
]
[project.scripts]
dmarc-to-discord = "dmarc_to_discord:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Generated
+97
View File
@@ -0,0 +1,97 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "dmarc-to-discord"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.31" }]
[[package]]
name = "idna"
version = "3.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]