Where links hide in an email
A transactional email usually has an HTML part and a plain-text part. The HTML has the button (<a href>) and often a fallback "copy this URL" line; the text part has the URL written out. Logos, footers and unsubscribe links add more. A typical sign-up email has five to ten links, so "the first link" is a guess that breaks the day marketing adds a logo.
Choose by meaning instead: the anchor text ("Confirm email") or the path (/verify, /auth/callback). Check that exactly one link matches, so a template change fails loudly.
type Link = { url: string; text: string };
export function pickLink(links: Link[], test: (l: Link) => boolean): string {
const found = links.filter(test);
if (found.length !== 1) {
throw new Error(`Expected 1 matching link, got ${found.length}: ${
links.map((l) => l.url).join(", ")
}`);
}
return found[0]!.url;
}
// pickLink(email.links, (l) => new URL(l.url).pathname === "/verify")Parsing HTML yourself: the traps
- Entities. In HTML,
&in a URL is written&. A regex over raw HTML returns?a=1&b=2, which your app may not accept. An HTML parser decodes it. - Quoted-printable. If you read the raw MIME message, long lines are wrapped with
=at the end and=3Dstands for=. Decode the part before searching. - Duplicates. The same URL in the button and in the text part. Deduplicate by URL.
Click tracking rewrites every link
Providers measure clicks by replacing each URL with one on their own domain that redirects to yours. SendGrid's click tracking "rewrites all the links and URLs in your emails" (plain-text emails only if you opt in). Postmark's link tracking replaces links with ones on click.pstmrk.it. The test then sees a tracking URL: the path check fails, and visiting it registers a click in your provider's stats.
Three ways out, best first:
- Turn tracking off for staging. In Postmark,
TrackLinksis set per server or per message (Noneis the default). In SendGrid, tracking is an account setting, and the mail send API takestracking_settings.click_trackingper message. - Turn it off for transactional streams only. Sign-up and reset emails rarely need click stats, and users see the real URL.
- Follow the redirect once and read the target without opening it in the browser:
// Note: this request counts as a click in your provider's stats.
export async function resolveTracked(url: string): Promise<string> {
const res = await fetch(url, { redirect: "manual" });
return res.headers.get("location") ?? url;
}