DEV Community

linkbruno.com
linkbruno.com

Posted on

Quick tip: your link checker probably skips mailto: and tel: links. Check them yourself in 40 lines

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}")
Enter fullscreen mode Exit fullscreen mode

Run it against built HTML files:

python3 contact_links.py public/**/*.html
Enter fullscreen mode Exit fullscreen mode

(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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Things to notice:

  • Query strings are handled. mailto:sales@example.com?subject=Hi is fine; urlparse puts ?subject=Hi in the query, not the path.
  • Multiple recipients work. mailto:a@example.com,b@example.com is 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 0000 dials 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 []
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 as tel:
  • javascript:void(0) placeholders left on <a> tags that should be buttons
  • empty href="" or href="#" 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

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)