Compare commits
12 Commits
628e09423b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb6258211f
|
|||
|
0e51550cc8
|
|||
|
4b8a187dbd
|
|||
|
18953fde8e
|
|||
|
9f713fb549
|
|||
|
3e1262b9e4
|
|||
|
59846d9e9b
|
|||
|
dd20d707f8
|
|||
|
1edd0419df
|
|||
|
bd7be8138a
|
|||
|
5848801699
|
|||
|
3aeb00d7b1
|
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
dmarc-to-discord.service
|
||||
+35
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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
@@ -8,12 +8,14 @@ it to Discord embeds and forward.
|
||||
|
||||
Env vars:
|
||||
DISCORD_WEBHOOK_URL required
|
||||
WEBHOOK_SECRET optional; if set, POSTs must arrive at /<secret>
|
||||
LISTEN_HOST default 127.0.0.1
|
||||
LISTEN_PORT default 8080
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -21,10 +23,12 @@ import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
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_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] + "..."
|
||||
|
||||
|
||||
def result_icon(r): return {"pass": "✅", "fail": "❌", "softfail": "⚠️",
|
||||
"neutral": "➖", "temperror": "⚠️", "permerror": "⚠️"}.get(r or "", "❓")
|
||||
def result_icon(r): return {"pass": "✅", "fail": "❌", "softfail": "⚠️", "neutral": "➖",
|
||||
"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):
|
||||
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
|
||||
if record.get("policy_evaluated", {}).get("disposition") in ("quarantine", "reject"):
|
||||
if dmarc is False:
|
||||
return COLOR_FAIL
|
||||
return COLOR_PARTIAL
|
||||
return COLOR_PARTIAL # alignment missing → unknown
|
||||
|
||||
|
||||
def build_metadata_embed(report):
|
||||
@@ -60,9 +68,13 @@ def build_metadata_embed(report):
|
||||
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"))
|
||||
|
||||
reporter_lines = [meta.get("org_name", "unknown"), meta.get("org_email", "")]
|
||||
if extra := meta.get("org_extra_contact_info"):
|
||||
reporter_lines.append(extra)
|
||||
|
||||
fields = [
|
||||
{"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": "Timespan (UTC)",
|
||||
"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"
|
||||
f"**p / sp:** `{policy.get('p', '?')}` / `{policy.get('sp', '?')}`\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},
|
||||
{"name": "Summary",
|
||||
"value": f"**Records:** {len(records)}\n**Messages:** {total}\n**DMARC pass:** {passing} / {total}",
|
||||
"inline": False},
|
||||
]
|
||||
if errors := (meta.get("errors") or []):
|
||||
errors = meta.get("errors") or []
|
||||
if errors:
|
||||
fields.append({"name": "Errors",
|
||||
"value": truncate("\n".join(f"• {e}" for e in errors)), "inline": False})
|
||||
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", {})
|
||||
pol, ids, auth = record.get("policy_evaluated", {}), record.get("identifiers", {}), record.get("auth_results", {})
|
||||
rdns = src.get("reverse_dns") or "—"
|
||||
asn = src.get("asn")
|
||||
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 = [
|
||||
{"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},
|
||||
]
|
||||
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": "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",
|
||||
"value": f"{bool_icon(align.get('dmarc'))} DMARC\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):
|
||||
embeds = [build_metadata_embed(report)]
|
||||
records = report.get("records", [])
|
||||
policy_domain = (report.get("policy_published") or {}).get("domain")
|
||||
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):
|
||||
payload = {"embeds": embeds[i: i + MAX_EMBEDS_PER_MESSAGE], "username": "parsedmarc"}
|
||||
@@ -144,7 +221,21 @@ def send_to_discord(report):
|
||||
|
||||
|
||||
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):
|
||||
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))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
@@ -182,6 +273,10 @@ def main():
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user