How to Call an API from an Email
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.
The last thing the world needs is a cross-site scripting vulnerability in Outlook.
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.
AMP Email supported CSS. Notable omissions include:
!importantprefers-color-scheme:hasuser-selectclip-pathpointer-events@keyframes::beforeand::after
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.
You’re supposed to use <amp-img> instead, which Google calls a “powerful replacement”
that “may choose to delay or prioritize resource loading based on the viewport position.”
What this really means is, “the image will show a loading spinner for way longer than a normal image.”
I’ve also run into a weird bug where <amp-img> elements with an off-screen 3D
transform never stop showing a loading spinner, even if the image later enters
the viewport. Considering AMP devs have never done anything about my other bug
reports, I’ve never bothered to document this until now.
This is to prevent working around AMP’s rendering framework, but sometimes it causes weird issues with absolute positioning.
Amusingly, their validator isn’t robust enough to catch everything. Here is some actual code I’ve used to fix a rendering bug:
// AMP plays dirty by not allowing us to use the string "i-amphtml" in CSS, but they don't check for *="amphtml"
const ampPositioningOverrides = `.${sectionId} amp-list {
position: unset;
}
.${sectionId} [class*="amphtml-replaced-content"] {
position: unset;
}`;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.
However, at the very least, AMP lets you set the height of dynamic content
differently on mobile versus desktop via heights:
<amp-list
layout="responsive"
width="300"
height="200"
heights="(min-width:500px) 100px, 800px"
src="..."
></amp-list>Useful feature, right? Yeah, it sure would be useful if it were real! The docs
claimed this was possible for a long time, but it was never actually supported.
The AMP developers removed heights from the docs when I pointed it out.
I don’t blame the core AMP devs for this, though. Gmail ghosted me when I submitted a bug report.
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.
Further reading: Kill Google AMP before it kills the web (The Register) and Google’s AMP, the Canonical Web, and the Importance of Web Standards (EFF).
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.
The term “CSS crimes” was coined by users of the defunct social media site Cohost who used CSS to make interactive posts far beyond what was intended.
Consider the humble checkbox:
<input type="checkbox" />
By hiding the checkbox and linking it to a label, you can toggle arbitrary CSS.
<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.
<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:
<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;
}
</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 actually happen, but just in case, Redo has an “Interactive Email Doomsday” monitoring system so we can quickly detect and recover from this scenario.
I think the likelihood of this is extremely small, but unfortunately nonzero.
Email clients have a tendency to release breaking changes without telling anyone, and their behavior is largely undocumented and unspecified.
Apple Mail already bot-clicks <img> URLs, typically within about an hour of
sending an email. There is currently no email client or browser precedent for
bot-clicking CSS URLs.
Detecting bot clicks is possible in principle, although it’s hard to be 100% certain. So I recommend avoiding relying on bot detection heuristics for important clicks.
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.
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