Most link checkers do one thing: request each URL and look at the response. That works for https:// links. It doesn't work for mailto: and tel:, because there's nothing to request. So many tools quietly skip them.
Which is a shame, because they're often the most valuable links on the page. A typo in the "Email us" link on a contact page costs real enquiries, and nothing ever returns a 404 to tell you.
Here's a quick way to catch the obvious mistakes.
The script
#!/usr/bin/env python3
"""contact_links.py: flag suspicious mailto: and tel: links in HTML files."""
import re, sys
from html.parser import HTMLParser
from urllib.parse import urlparse, unquote
EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$", re.I)
TEL = re.compile(r"^\+?[0-9().\- ]{6,}$")
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
if tag == "a":
href = dict(attrs).get("href") or ""
self.links.append((self.getpos()[0], href.strip()))
def problems(href):
scheme = href.split(":", 1)[0].lower()
if scheme == "mailto":
addrs = unquote(urlparse(href).path).split(",")
bad = [a for a in addrs if not EMAIL.match(a.strip())]
return [f"bad address: {a!r}" for a in bad]
if scheme == "tel":
num = unquote(href[4:])
if not TEL.match(num):
return [f"bad number: {num!r}"]
if not num.startswith("+"):
return ["no country code (+...)"]
return []
for path in sys.argv[1:]:
p = Links()
p.feed(open(path, encoding="utf-8").read())
for line, href in p.links:
for msg in problems(href):
print(f"{path}:{line}: {href} -> {msg}")
Run it against built HTML files:
python3 contact_links.py public/**/*.html
(In bash, ** needs shopt -s globstar; or use find public -name '*.html' -exec python3 contact_links.py {} +.)
What it catches
Given this test file:
<a href="mailto:hello@example.com">Email</a>
<a href="mailto:hello@example">Typo</a>
<a href="mailto:sales@example.com?subject=Hi">Sales</a>
<a href="mailto: info@@example.com">Double at</a>
<a href="tel:+44 20 7946 0000">Call</a>
<a href="tel:020 7946 0000">Local</a>
<a href="tel:CALL-NOW">Letters</a>
The output is:
sample.html:2: mailto:hello@example -> bad address: 'hello@example'
sample.html:4: mailto: info@@example.com -> bad address: ' info@@example.com'
sample.html:6: tel:020 7946 0000 -> no country code (+...)
sample.html:7: tel:CALL-NOW -> bad number: 'CALL-NOW'
Things to notice:
-
Query strings are handled.
mailto:sales@example.com?subject=Hiis fine;urlparseputs?subject=Hiin the query, not the path. -
Multiple recipients work.
mailto:a@example.com,b@example.comis split and each address checked. -
The leading space is flagged.
mailto: info@...is a real-world copy-paste mistake. Some mail clients cope; some don't. -
Local phone numbers get a warning, not an error.
tel:020 7946 0000dials fine from within the country, but fails for visitors abroad and on some VoIP apps. Using the international format (+44...) works everywhere.
Why the email regex is deliberately loose
Email validation is famously a rabbit hole. The pattern here only checks the shape: something, an @, something, a dot, a TLD. It won't reject unusual-but-valid addresses, which is the right trade-off for a lint check. The goal is to catch typos like a missing .com or a doubled @, not to implement RFC 5322.
Going further
A few extensions that are worth it if contact links matter to your business:
Check the domain can receive mail. Look up MX records for each email domain (for example with the dnspython package). A typo like @gmial.com passes the shape check but may have no MX record, or one you definitely don't want to send to.
Compare against an allowlist. Most sites only have a handful of legitimate contact addresses. Keep them in a list and flag anything else:
ALLOWED = {"hello@example.com", "sales@example.com"}
def not_allowed(href):
if href.lower().startswith("mailto:"):
addrs = unquote(urlparse(href).path).split(",")
return [a for a in addrs if a.strip().lower() not in ALLOWED]
return []
This catches a much more common problem than typos: the old address of someone who left the company two years ago, still linked from a blog post.
Normalise phone numbers. If you want stronger checks, the phonenumbers library (a port of Google's libphonenumber) can tell you whether a number is valid for its country.
Run it in CI. It's fast and has no network dependency (unless you add the MX lookup), so it's cheap to run on every build. Exit with a non-zero status when there are problems:
found = False
# ... set found = True whenever you print a problem
sys.exit(1 if found else 0)
Other links that need special handling
While you're thinking about non-HTTP links, a few more that generic checkers often skip or misjudge:
-
sms:links, same shape issues astel: -
javascript:void(0)placeholders left on<a>tags that should be buttons - empty
href=""orhref="#"links that go nowhere - links to app stores or deep links (
myapp://...) that only work on certain devices
Each of these deserves at least a lint rule, even if you can't "request" them. A small extension of the same parser can flag empty and javascript: hrefs in a couple of lines.
A manual check worth doing once
Scripts catch malformed links, not wrong-but-valid ones. Once a year, open your contact page on a phone, tap every email and phone link, and make sure the right app opens with the right address or number. It takes two minutes and catches the problems no regex can: a perfectly formatted number that belongs to your old office.
Where this fits
This is a static check: it looks at the HTML, not at whether mail actually arrives. It complements regular link checking rather than replacing it. For the HTTP side, a crawler like linkbruno.com scans your pages for broken links on a schedule, and a script like this covers the contact links that don't have a status code to check.
Related reading
- Case study (hypothetical): moving from Windows to Linux hosting broke 1,900 links, all because of capital letters
- '0 broken links found' is a myth: 7 places link checkers don't look
- Soft 404s: your 'page not found' returns 200, and your link checker thinks it's fine
- Check links in Markdown docs with a 60-line Python script
- HEAD vs GET for link checking: HEAD is faster, and it lies more than you'd think
Have you ever found a broken mailto: or tel: link on a live site, maybe your own? How long had it been there? Tell me in the comments.
Top comments (0)