Mail, DNS & config rules
Three checks beyond the HTTPS deep scan. All are browser-delivered: users type a domain or paste a file, the server does the work, nothing is downloaded or run locally.
- Mail TLS — the transport on SMTP/IMAP/POP3 ports.
- Mail DNS — SPF, DKIM, DMARC, MTA-STS, TLS-RPT.
- Config analysis — Docker Compose + Traefik labels, Traefik file config, Nginx, Caddy.
Mail TLS
Section titled “Mail TLS”A mail target is a domains row with kind = 'mail' (port 0, so the same host can be tracked
as both a web and a mail target). One row covers the whole stack: its scan fans out to every
selected port and to DNS, and stores one composite result.
Where it looks
Section titled “Where it looks”The domain a user types is rarely the host their mail runs on, so the scan follows the MX records and probes the client ports there. Two refinements matter:
- Port 25 is probed on every MX, not just the primary — a neglected backup MX is a classic weak link.
- Client ports fall back to the apex domain when the MX host doesn’t answer. A domain behind a filtering MX (Proofpoint, Mimecast, a spam gateway) runs submission somewhere else entirely.
When nothing answers and the MX host differs from the domain, the error says so explicitly rather than repeating four identical timeouts.
| Port | Protocol | TLS | Default |
|---|---|---|---|
| 587 | SMTP submission | STARTTLS | ✓ |
| 465 | SMTPS | implicit | ✓ |
| 993 | IMAPS | implicit | ✓ |
| 995 | POP3S | implicit | ✓ |
| 25 | SMTP relay (MX) | STARTTLS | opt-in |
| 143 | IMAP | STARTTLS | opt-in |
| 110 | POP3 | STLS | opt-in |
25/143/110 are opt-in because they are legacy or server-to-server, and their failures read as noise on a typical setup. Port 25 is also blocked on most cloud egress paths.
What a STARTTLS port is asked
Section titled “What a STARTTLS port is asked”A STARTTLS port opens in the clear and only becomes encrypted if the server advertises the upgrade and the client asks. Two facts are therefore reported before any certificate is examined:
- Is the upgrade offered at all? If not, every session on that port is permanently plaintext.
- Is authentication offered before the upgrade? SMTP advertising
AUTH, or IMAP not advertisingLOGINDISABLED, means credentials can cross in the clear — and an attacker who strips the advertisement can force exactly that.
Only the capability announcement is read. No credentials are ever sent and no mail is submitted.
Two things that had to be got right
Section titled “Two things that had to be got right”Version probing is serial. Mail servers cap concurrent connections per client as standard
anti-abuse configuration (Postfix’s smtpd_client_connection_count_limit). Probing the four TLS
versions in parallel earns a 421 too many connections, which is indistinguishable from the server
refusing that version — it fabricated “TLS 1.2 disabled” findings against a perfectly healthy
server. Mail probes run one at a time; the HTTPS path is unaffected and stays parallel.
A failure before the handshake is indeterminate, not disabled. “Not tested” and “refused”
are different claims, and only one of them is a finding.
Time budget
Section titled “Time budget”Serial probing is slow: one blackholing STARTTLS port can spend 110s in timeouts, and all seven
ports could reach ~440s — past the job runner’s DEVTLS_SCAN_TIMEOUT (180s default). Being killed
there discards every result, so the scan stops itself first at two thirds of the job cap and
reports which ports it never reached.
Grading
Section titled “Grading”Separate from src/grade.ts — see src/grade-mail.ts. The overall grade
is the worst endpoint’s, because an attacker picks the port. Two rules differ from the web
grader deliberately:
- HSTS is not considered. It is an HTTP header; port 993 has none.
- A hostname mismatch is a 10-point warning, not a cap. Browsers reject a mismatched web certificate outright, but server-to-server SMTP is opportunistic and routinely presents a certificate for the mail host rather than the domain. Capping would bury the real findings.
| Finding | Severity |
|---|---|
| STARTTLS not offered on a STARTTLS port | critical (forces F) |
| Authentication offered before TLS | critical |
| Expired certificate · SSLv2/SSLv3 enabled | critical (forces F) |
| TLS 1.0 · weak cipher · untrusted chain · self-signed | high |
| TLS 1.1 · no forward secrecy · hostname mismatch | medium |
| No TLS 1.3 · certificate expiring soon | low–medium |
| A selected port that never answered | low, no score penalty |
That last row matters: most stacks run only some of these ports, so penalizing a closed one would manufacture failures.
Mail DNS
Section titled “Mail DNS”See src/engine/mail/dns.ts and
src/grade-mail-dns.ts. Every finding ships a copy-paste-ready record,
because the fix for all of these is a DNS entry someone has to type correctly.
- SPF — the
allqualifier, and the DNS lookup count followinginclude:andredirect=recursively. Exceeding RFC 7208’s limit of 10 is apermerror: receivers discard SPF entirely, so a domain can look perfectly configured and authenticate nowhere. This cannot be seen without recursing.+allis treated as critical — worse than no SPF, since it actively vouches for forgeries. - DKIM — user-supplied selectors, or a probe of common ones. Key type and size are read from
the
p=tag. Selectors are not discoverable from DNS, so a probe miss is not proof of absence and the finding says so and softens tomedium. - DMARC — policy strength,
pct,rua, andsp=noneexempting subdomains. - MTA-STS — the
_mta-stsTXT and the policy fetched over HTTPS, since onlymode: enforceactually stops a sender falling back to plaintext. - TLS-RPT — the reporting record.
TXT records are reassembled from their DNS character-strings before parsing. This is not cosmetic: GitHub’s SPF record is split mid-IP-address, and parsing the chunks separately yields garbage.
DNS is capped at 40 points of the mail score. Sound transport with no email authentication should land around C — “your transport is fine, your authentication is missing” — rather than F.
Config analysis
Section titled “Config analysis”See src/config/. Entirely offline: nothing referenced in a pasted config is
ever fetched, so no SSRF surface is introduced.
POST /api/config-checks accepts { content, kind?, name?, save? }. Omit kind to auto-detect.
A syntax error returns 400 with code: "PARSE_ERROR" and a line number — never a 500.
Privacy
Section titled “Privacy”Pasted configuration routinely carries basicauth hashes, API keys and database passwords.
Retention is opt-in (save defaults to false), and anything stored is redacted first —
passwords, tokens, keys, bcrypt hashes and PEM blocks. The findings are always kept; the
credentials never are.
Traefik (via Docker labels)
Section titled “Traefik (via Docker labels)”Compose’s ${VAR:-default} interpolation is resolved before any comparison, and network aliases
are resolved to real Docker network names. Where a variable cannot be resolved statically, the rule
declines to judge rather than guessing — otherwise it reports findings about the reader’s
missing .env file.
Checks include: traefik.enable missing or false; routers with no rule or no entrypoint; plain
HTTP with no redirect; TLS without a certificate resolver; TLS with no tls.options; no HSTS
middleware; --api.insecure=true; an unauthenticated dashboard router;
insecureSkipVerify; host ports that bypass the proxy; network mismatches.
The
tls.optionsrule is the one that matters most here. Without it Traefik falls back to defaults that still accept TLS 1.0/1.1 — the site gets a valid certificate and a padlock while negotiating deprecated protocols, and a live scan grades it around C. It is exactly whydevtls.fleeq.netitself graded C; seedeploy/traefik-tls-options.yml.
A real tokenizer, not regexes — a CSP value like "default-src 'self'; script-src 'self'" contains
both ; and {, the characters that terminate directives and open blocks.
The highest-value rule is the add_header inheritance trap: nginx replaces the add_header
set rather than merging it, so a location that adds one header silently drops every header its
server sets. A site passes a header check at the server level and sends nothing on the paths that
matter. Flagged high, naming each header that is lost.
Also: legacy ssl_protocols; weak ssl_ciphers; port-80 servers that serve instead of
redirecting; missing HSTS and security headers; add_header without always (skipped on error
responses); server_tokens on; plaintext non-local proxy_pass.
Caddy obtains certificates, redirects HTTP→HTTPS and requires TLS 1.2 without being asked, so
these rules look for deliberate downgrades rather than missing hardening: auto_https off,
tls internal on a public host, protocols tls1.0, tls_insecure_skip_verify, missing security
headers. A clean Caddyfile produces zero findings.
Plan limits
Section titled “Plan limits”Data-driven in src/plans.ts, so changing them needs no migration.
| Free | Pro | Team | Founder | |
|---|---|---|---|---|
| Mail scans | ✓ | ✓ | ✓ | ✓ |
| Config checks / month | 10 | 200 | 1000 | 1000 |
Mail targets count against maxDomains like any other row.