Skip to content

Commit e0d03c5

Browse files
authored
Merge pull request #17 from davidhuser/dev
2 parents 28ffd4f + dd4c898 commit e0d03c5

60 files changed

Lines changed: 167222 additions & 49056 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/nightly.yml

Lines changed: 0 additions & 75 deletions
This file was deleted.

.github/workflows/preview.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Deploy preview
2+
3+
on:
4+
push:
5+
branches: [dev]
6+
7+
jobs:
8+
preview:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v6
12+
13+
- name: Create Cloudflare Pages project (if needed)
14+
uses: cloudflare/wrangler-action@v3
15+
with:
16+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
17+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
18+
command: pages project create mxmap-ch-preview --production-branch=dev
19+
continue-on-error: true
20+
21+
- name: Deploy to Cloudflare Pages
22+
uses: cloudflare/wrangler-action@v3
23+
with:
24+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
25+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
26+
command: pages deploy . --project-name=mxmap-ch-preview

.github/workflows/release.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Create Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
release:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v6
15+
with:
16+
fetch-depth: 0
17+
18+
- name: Determine tag
19+
id: tag
20+
run: |
21+
base="v$(date -u +%Y.%m.%d)"
22+
existing=$(git tag -l "${base}*" | wc -l | tr -d ' ')
23+
if [ "$existing" -eq 0 ]; then
24+
echo "tag=${base}" >> "$GITHUB_OUTPUT"
25+
else
26+
echo "tag=${base}.${existing}" >> "$GITHUB_OUTPUT"
27+
fi
28+
29+
- name: Create release
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
run: gh release create "${{ steps.tag.outputs.tag }}" --generate-notes

CLAUDE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# CLAUDE.md
2+
3+
MXmap (mxmap.ch) — an automated system that classifies where ~2100 Swiss municipalities host their email by fingerprinting DNS records and network infrastructure. Results are displayed on an interactive Leaflet map.
4+
5+
## Commands
6+
7+
```bash
8+
# Setup
9+
uv sync --group dev
10+
11+
# Run pipeline (two stages, in order)
12+
uv run resolve-domains # Stage 1: resolve municipality domains
13+
uv run classify-providers # Stage 2: classify email providers
14+
15+
# Test
16+
uv run pytest --cov --cov-report=term-missing # 90% coverage threshold enforced
17+
uv run pytest tests/test_probes.py -k test_mx # single test
18+
uv run pytest tests/test_data_validation.py -v # data validation (requires JSON files)
19+
20+
# Lint & format
21+
uv run ruff check src tests
22+
uv run ruff format src tests
23+
```
24+
25+
### Data Files
26+
27+
- `overrides.json` — manual classification corrections with reasons
28+
- `municipality_domains.json` — intermediate output from resolve stage
29+
- `data.json` — final classifications served to the frontend
30+
31+
32+
### What not to do
33+
34+
- modify any data files directly without approval (especially `data.json` and `municipality_domains.json`, which are generated by the pipeline)
35+
- run the pipeline directly since it can time out and hides warnings

README.md

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# MXmap — Email Providers of Swiss Municipalities
22

33
[![CI](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml/badge.svg)](https://github.com/davidhuser/mxmap/actions/workflows/ci.yml)
4-
[![Nightly](https://github.com/davidhuser/mxmap/actions/workflows/nightly.yml/badge.svg)](https://github.com/davidhuser/mxmap/actions/workflows/nightly.yml)
54

65
An interactive map showing where Swiss municipalities host their email — whether with US hyperscalers (Microsoft, Google, AWS) or Swiss providers or other solutions.
76

@@ -11,66 +10,57 @@ An interactive map showing where Swiss municipalities host their email — wheth
1110

1211
## How it works
1312

14-
The data pipeline has three steps:
13+
The data pipeline has two stages:
1514

16-
1. **Preprocess** -- Fetches all ~2100 Swiss municipalities from Wikidata, performs MX and SPF DNS lookups on their official domains, and classifies each municipality's email provider.
17-
2. **Postprocess** -- Applies manual overrides for edge cases, retries DNS for unresolved domains, checks SMTP banners of independent MX hosts for hidden providers, then scrapes websites of still-unclassified municipalities for email addresses.
18-
3. **Validate** -- Cross-validates MX and SPF records, assigns a confidence score (0-100) to each entry, and generates a validation report.
15+
1. **Resolve domains** Fetches all ~2100 Swiss municipalities from Wikidata and the BFS (Swiss Statistics) API, applies manual overrides, scrapes municipal websites for email addresses, guesses domains from municipality names, and verifies candidates with MX lookups. Scores source agreement to pick the best domain. Outputs `municipality_domains.json`.
16+
17+
2. **Classify providers** — For each resolved domain, looks up all MX hosts, pattern-matches them, then runs 10 concurrent probes (SPF, DKIM, DMARC, Autodiscover, CNAME chain, SMTP banner, Tenant, ASN, TXT verification, SPF IP). Aggregates weighted evidence, computes confidence scores (0100). Outputs `data.json` (full) and `data.min.json` (minified for the frontend).
1918

2019
```mermaid
2120
flowchart TD
22-
trigger["Nightly trigger"] --> wikidata
23-
24-
subgraph pre ["1 · Preprocess"]
25-
wikidata[/"Wikidata SPARQL"/] --> fetch["Fetch ~2100 municipalities"]
26-
fetch --> domains["Extract domains +<br/>guess candidates"]
27-
domains --> dns["MX + SPF lookups<br/>(3 resolvers)"]
28-
dns --> spf_resolve["Resolve SPF includes<br/>& redirects"]
29-
spf_resolve --> cname["Follow CNAME chains"]
30-
cname --> asn["ASN lookups<br/>(Team Cymru)"]
31-
asn --> autodiscover["Autodiscover DNS<br/>(CNAME + SRV)"]
32-
autodiscover --> gateway["Detect gateways<br/>(SeppMail, Barracuda,<br/>Proofpoint, Sophos ...)"]
33-
gateway --> classify["Classify providers<br/>MX → CNAME → SPF → Autodiscover → SMTP"]
21+
subgraph resolve ["1 · Resolve domains"]
22+
bfs[/"BFS Statistics API"/] --> merge["Merge ~2100 municipalities"]
23+
wikidata[/"Wikidata SPARQL"/] --> merge
24+
overrides[/"overrides.json"/] --> per_muni
25+
merge --> per_muni["Per municipality"]
26+
per_muni --> scrape["Scrape website for<br/>email addresses"]
27+
per_muni --> guess["Guess domains<br/>from name"]
28+
scrape --> mx_verify["MX lookup to<br/>verify domains"]
29+
guess --> mx_verify
30+
mx_verify --> score["Score source<br/>agreement"]
3431
end
3532
36-
classify --> overrides
33+
score --> domains[("municipality_domains.json")]
34+
domains --> classify_in
3735
38-
subgraph post ["2 · Postprocess"]
39-
overrides["Apply manual overrides<br/>(19 edge cases)"] --> retry["Retry DNS<br/>for unknowns"]
40-
retry --> smtp["SMTP banner check<br/>(EHLO on port 25)"]
41-
smtp --> scrape_urls["Probe municipal websites<br/>(/kontakt, /contact, /impressum …)"]
42-
scrape_urls --> extract["Extract emails<br/>+ decrypt TYPO3 obfuscation"]
43-
extract --> scrape_dns["DNS lookup on<br/>email domains"]
44-
scrape_dns --> reclassify["Reclassify<br/>resolved entries"]
36+
subgraph classify ["2 · Classify providers"]
37+
classify_in["Per unique domain"] --> mx_lookup["MX lookup<br/>(all hosts)"]
38+
mx_lookup --> mx_match["Pattern-match MX<br/>+ detect gateway"]
39+
mx_match --> concurrent["10 concurrent probes<br/>SPF · DKIM · DMARC<br/>Autodiscover · CNAME chain<br/>SMTP · Tenant · ASN<br/>TXT verification · SPF IP"]
40+
concurrent --> aggregate["Aggregate weighted<br/>evidence"]
41+
aggregate --> vote["Primary vote<br/>+ confidence scoring"]
4542
end
4643
47-
reclassify --> data[("data.json")]
48-
data --> score
44+
vote --> data[("data.json + data.min.json")]
45+
data --> frontend["Leaflet map<br/>mxmap.ch"]
46+
```
4947

50-
subgraph val ["3 · Validate"]
51-
score["Confidence scoring · 0–100"] --> gwarn["Flag potential<br/>unknown gateways"]
52-
gwarn --> gate{"Quality gate<br/>avg ≥ 70 · high-conf ≥ 80%"}
53-
end
48+
## Classification system
5449

55-
gate -- "Pass" --> deploy["Commit & deploy to Pages"]
56-
gate -- "Fail" --> issue["Open GitHub issue"]
50+
see [`classifier.py`](src/mail_sovereignty/classifier.py) for the full implementation details, but in summary,
51+
we use a weighted evidence system where each probe contributes signals of varying strength towards different provider classifications.
5752

58-
style trigger fill:#e8f4fd,stroke:#4a90d9,color:#1a5276
59-
style wikidata fill:#e8f4fd,stroke:#4a90d9,color:#1a5276
60-
style data fill:#d5f5e3,stroke:#27ae60,color:#1e8449
61-
style deploy fill:#d5f5e3,stroke:#27ae60,color:#1e8449
62-
style issue fill:#fadbd8,stroke:#e74c3c,color:#922b21
63-
style gate fill:#fdebd0,stroke:#e67e22,color:#935116
64-
```
6553

6654
## Quick start
6755

6856
```bash
6957
uv sync
7058

71-
uv run preprocess
72-
uv run postprocess
73-
uv run validate
59+
# Stage 1: resolve municipality domains
60+
uv run resolve-domains
61+
62+
# Stage 2: classify email providers
63+
uv run classify-providers
7464

7565
# Serve the map locally
7666
python -m http.server
@@ -81,20 +71,36 @@ python -m http.server
8171
```bash
8272
uv sync --group dev
8373

84-
# Run tests with coverage
74+
# Run tests (90% coverage threshold enforced)
8575
uv run pytest --cov --cov-report=term-missing
8676

87-
# Lint the codebase
77+
# Lint & format
8878
uv run ruff check src tests
8979
uv run ruff format src tests
9080
```
9181

82+
9283
## Related work
9384

9485
* [hpr4379 :: Mapping Municipalities' Digital Dependencies](https://hackerpublicradio.org/eps/hpr4379/index.html)
95-
* if you know of other similar projects, please open an issue or submit a PR to add them here!
86+
* If you know of other similar projects, please open an issue or submit a PR to add them here!
87+
88+
## Forks
89+
90+
* DE https://b42labs.github.io/mxmap/
91+
* NL https://mxmap.nl/
92+
* NO https://kommune-epost-norge.netlify.app/
93+
* BE https://mxmap.be/
94+
* EU https://livenson.github.io/mxmap/
95+
* LV: https://securit.lv/mxmap
96+
* See also the forks of this repository
97+
9698

9799
## Contributing
98100

99101
If you spot a misclassification, please open an issue with the BFS number and the correct provider.
100-
For municipalities where automated detection fails, corrections can be added to the `MANUAL_OVERRIDES` dict in `src/mail_sovereignty/postprocess.py`.
102+
For municipalities where automated detection fails, corrections can be added to [`overrides.json`](overrides.json).
103+
104+
## Licence
105+
106+
[MIT](LICENCE)

css/content.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* content.css — content page styles (datenschutz, impressum) */
2+
body { color: #222; background: #fff; }
3+
4+
main {
5+
max-width: 720px; margin: 0 auto; padding: 32px 20px 48px;
6+
line-height: 1.7; font-size: 15px;
7+
}
8+
h1 { font-size: 22px; font-weight: 700; color: #1a1a2e; margin-bottom: 24px; }
9+
h2 { font-size: 17px; font-weight: 600; color: #1a1a2e; margin-top: 28px; margin-bottom: 8px; }
10+
p { margin-bottom: 12px; }
11+
ul { margin: 0 0 12px 20px; }
12+
li { margin-bottom: 4px; }
13+
a { color: #2563eb; text-decoration: none; }
14+
a:hover { text-decoration: underline; }
15+
code {
16+
font-family: ui-monospace, monospace; font-size: 13px;
17+
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
18+
}
19+
20+
footer {
21+
max-width: 720px; margin: 0 auto; padding: 24px 20px;
22+
border-top: 1px solid #e2e4e8; font-size: 13px; color: #888;
23+
}
24+
footer a { color: #888; }
25+
footer a:hover { color: #555; }

0 commit comments

Comments
 (0)