Emails can’t do much. They’re built from the same core technologies as webpages, HTML and CSS, but they can’t run JavaScript. This limits interactivity. You can’t do much in an email besides click links.
That’s what Big Email wants you to think.
Turns out emails can do a lot if you know a few tricks. For example, shopping carts require math, state management, and network requests. No problem:
Redo sends millions of emails like this. No JavaScript. 70% email client support.
Odds are you’re writhing on the floor in shock. How? Short answer: dozens of obscure tricks. Too many for one post. I’ll focus on my second favorite: how to call an API without leaving the email.
Let’s use Redo’s subscription manager as an example:
Clicking that button notifies a subscription service to delay the next shipment. No JavaScript nor redirect. It works by combining two unrelated technologies: AMP Email and CSS crimes.
AMP Email
The most mainstream technique for interactive emails is a Google framework
called AMP Email. AMP provides a limited set of
tools like <amp-form> for submitting data to a server without redirecting. It
works in Gmail and Yahoo.
AMP seems straightforward at first glance, but using it is pretty annoying. Allow me to vent a year of grievances:
AMP has limited HTML/CSS support
In most email clients, HTML and CSS support is stuck in the 90’s. AMP emails are
stuck in 2015. An improvement, sure, but that’s still a lot of features you
can’t use, like dark mode
and the :has selector.
Additionally, AMP emails are extremely strict. If an email uses any unsupported feature, it won’t just ignore it; it will refuse to render the email. A few are easy to stumble into by accident:
- More than one
<style>element <img>elementshttp://urls instead ofhttps://<svg>elements- Selectors targeting AMP internal features
i-amphtml - Any web feature released in the last ten years
Ask me how I know.
On that note, AMP is just weirdly opinionated about some things. For example, dynamic content in an AMP email must either know its height or aspect ratio up front:
<amp-list
layout="responsive"
width="300"
height="200"
src="https://amp.dev/static/samples/json/examples.json"
>
<template type="amp-mustache"> <div>{{title}}</div> </template>
</amp-list>
This prevents annoying layout shifts where content in the email gets pushed down as content loads. Sure, that is good for the user experience, but it’s also severely limiting in certain situations. The whole point of AMP email is showing dynamic content. Sometimes you don’t know in advance how tall dynamic content is going to be. AMP refuses to budge on this.
AMP is basically abandonware
AMP Email is a subset of a larger initiative from Google called The AMP Project, intended to speed up websites. The AMP framework was universally hated and is rarely used today.
AMP Email is annoying for all the same reasons that AMP is. It hasn’t had a significant update in years, so I think it’s likely to end up on Killed by Google at some point, although I have no insider information so don’t quote me on that.
Feature requests get ignored and bugs aren’t really bugs.
Getting AMP approved is tedious
AMP emails can only be sent from approved email addresses. Approval involves submitting a Google Form and sending a production-ready email to Google, Yahoo, and Mail.ru to be checked by a human. It takes around five days to hear back, which is not fun after the first couple hundred times. To those hoping to automate this: beware, there is a CAPTCHA at the end of the form. Do with that info as you will.
In short, AMP Email is annoyingly opinionated and covered in red tape. And all that effort for what? Gmail and Yahoo only have 25% market share combined. All that work and three-quarters of people won’t even see it.
AMP isn’t enough for wide coverage. The solution is CSS.
Violating the CSS Geneva Convention
To normies, CSS is for styles. To high-agency, bar-raising thought leaders, CSS is for running Doom.
Turns out CSS is Turing complete, so you can get pretty far without JavaScript. Exploiting CSS beyond sane limits is called “CSS crimes,” and it’s an art form.
Consider the humble checkbox:
<input type="checkbox" />
By hiding the checkbox and linking it to a label, you can toggle arbitrary CSS.
HTML
<input type="checkbox" id="cb" />
<label for="cb">Click me</label>
<style>
#cb {
display: none;
}
label[for="cb"] {
background-color: red;
}
#cb:checked ~ label {
background-color: blue;
}
</style>
<style>
/* unimportant styles */
html, body {
height: 100%;
margin: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
}
label[for="cb"] {
cursor: pointer;
user-select: none;
padding: 0.5em 1em;
border-radius: 8px;
color: white;
}
</style> It just so happens that CSS can conditionally load an image.
If you’re on a desktop web browser, open DevTools, go to the Network tab, then click the button below.
HTML
<input type="radio" id="trigger" />
<label for="trigger">Click me (with DevTools open)</label>
<style>
#trigger {
display: none;
}
#trigger:checked ~ label {
background-image: url(
https://redo.com/eng-blog/x/example-img/
);
background-size: cover;
background-repeat: no-repeat;
}
</style>
<style>
/* unimportant styles */
html, body {
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1em;
}
label {
cursor: pointer;
user-select: none;
padding: 0.5em 1em;
border: 1px solid #ccc;
}
</style> Notice that the network request only fires after the checkbox is checked. The
browser won’t load a background-image until necessary. Now put it to work:
HTML
<input type="checkbox" id="delay-week" />
<input type="checkbox" id="delay-month" />
<div class="subscription-card">
Your subscription is coming up. Delay it?
<label for="delay-week">Delay 1 week</label>
<label for="delay-month">Delay 1 month</label>
<p class="submit-success">
Your next shipment will arrive in
<strong class="week">1 week</strong>
<strong class="month">1 month</strong>
</p>
</div>
<style>
#delay-week:checked ~ .subscription-card {
/* Arbitrary API call ↓ */
background-image: url(
https://redo.com/eng-blog/x/delay?days=7
);
}
#delay-month:checked ~ .subscription-card {
background-image: url(
https://redo.com/eng-blog/x/delay?days=30
);
}
#delay-week,
#delay-month {
display: none;
}
.submit-success {
display: none;
}
.submit-success .week,
.submit-success .month {
display: none;
}
input[id^="delay"]:checked ~
.subscription-card
.submit-success {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
#delay-week:checked ~ .subscription-card .week {
display: block;
font-size: 1.4em;
margin-top: 0.3em;
}
#delay-month:checked ~ .subscription-card .month {
display: block;
font-size: 1.4em;
margin-top: 0.3em;
}
/* A URL pointed to by an arrow reveals arcane knowledge */
</style>
<style>
/* unimportant styles */
html, body {
height: 100%;
margin: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
padding: 1em;
box-sizing: border-box;
}
.subscription-card {
position: relative;
background-color: white;
padding: 1em;
margin-top: 1em;
border: 1px solid #ccc;
text-wrap: pretty;
}
label[for^="delay"] {
display: block;
margin-top: 1em;
padding: 0.5em 1em;
cursor: pointer;
border: 1px solid #ccc;
user-select: none;
}
input[id^="delay"]:checked ~ .subscription-card .submit-success {
background-color: white;
padding: 1em;
text-align: center;
}
</style> What is “loading an image” if not an HTTP GET request? And what is a GET
request if not an API call? Configure your server to
return a transparent pixel, then perform arbitrary side effects. Bam, AJAX
email.
Sure, POST would be ideal. But life, lemons, whatever.
The technique above works in AMP emails, Apple Mail, Thunderbird, and the newest version of Outlook. That’s like 70% coverage.
Limitations of image-based API calls
Obviously this is a hack. There are two main drawbacks to interpreting lazy-loaded CSS background images as API calls:
First, you can only detect the first time a button is clicked. You can partially work around this by making multiple identical buttons, only showing one at a time, and making each click hide the current button and show the next one.
Second, there is no guarantee the image technique will work forever. There’s no
official CSS specification that says image URLs inside :checked selectors
should be lazy loaded. This behavior is universal but not officially
standardized.
For example, the day may come when Apple Mail silently introduces bot clicks on all CSS URLs. If this ever happens, the image technique breaks down since it doesn’t distinguish between real and bot clicks. I doubt this will ever happen, but just in case, Redo has an “Interactive Email Doomsday” monitoring system so we can quickly detect and recover from this scenario.
Final thoughts
Fellas, is it AI to have a heading named “Conclusion”?
I haven’t yet spilled all the secrets. For a production-ready email you also have to consider:
- Error handling. How can you show an error message if the API call fails?
- State restoration. If the user performs an action, then closes and reopens the email, how do you restore its state?
- Arithmetic. How can you do client-side math without JavaScript?
I won’t deprive the reader of the joy of solving these problems. Yet.
CTRL-F for arcane knowledge.
Further reading
Here are some of the best resources for learning interactive email techniques:
- Mark Robbins video (the OG interactive email expert)
- AMP Email documentation
- Committing CSS Crimes
- Reverse-engineering CodePen CSS-only video games
- CSS Minecraft