Recently, I worked on embedding HTML emails into a web page for Mailgrip.
I’d done something similar in the past using iFrames, but this time used the Declarative Shadow DOM instead.
It resulted in a much easier implementation, with less reliance on client-side JavaScript.
This post provides an introduction to the Declarative Shadow DOM, and how it compares to the regular ol’ Shadow DOM.
Why not just insert the email’s HTML into the page?
First, an aside about the obvious anti-pattern here; taking the email’s HTML and inserting it as-is into the web page.
This would be very unreliable. Emails come with their own CSS styles (some inline, some via stylesheets) which would clash with the styles on the host page they’re being embedded into.
Likewise, styles from the host page would cascade down into the email and style it in an undesired way.
So… enforcing separation between the email’s DOM and CSS and those of the host page’s is really really necessary.
Enter the Shadow realm DOM
Shadow DOM is a web standard that allows you to attach a self-contained DOM tree to an existing DOM tree.
Styles in the Shadow DOM’s tree are totally separate from those on your host page, so it’s perfect for embedding HTML emails.
Typically to create a Shadow DOM you’d have to use JavaScript, and it’d look something like this:
<html><head><title>Host page</title><style>*{background-color:lightblue;font-family:serif;}</style></head><body>
Host page content.
<divid="host"></div><script>consthostElement=document.getElementById("host");constshadowRoot=hostElement.attachShadow({mode:"open"});// Whatever CSS styles you apply won't cross the boundary between the host// page and the Shadow DOM.shadowRoot.innerHTML=`
<h1>Inner content (DOMception)</h1>
<style>
* {
background-color: salmon;
font-family: monospace;
}
</style>
`;</script></body></html>
So, in this implementation we:
Create our host page, which includes a container element to host the shadow DOM id="host" in this case,
Add content and styles to the shadow root by assigning innerHTML (for simplicity. Any DOM manipulation function would work).
… and that all comes together into a page that looks like this:
The blue-ish parts are the host DOM, and the salmon-ish parts are in the Shadow DOM.
Note how the monospace styles from inside the Shadow DOM’s stylesheet don’t leak outside of its bounds.
(This is a very simplified example. Check out the MDN docs to go deeper.)
What does the Declarative Shadow DOM add?
It takes the Shadow DOM you already know and love and makes it more… declarative.
That means you can specify a shadow DOM directly in your existing HTML, without relying on JavaScript.
This is a huge boon for simplicity, and a plus for server-side rendered apps that try to use as little JavaScript as possible (like Mailgrip).
Using Declarative Shadow DOM, the example from above becomes:
<html><head><title>Host page</title><style>*{background-color:lightblue;font-family:serif;}</style></head><body>
Host page content.
<divid="host"><templateshadowrootmode="open"><h1>Shadow DOM content (DOMception).</h1><style>*{background-color:salmon;font-family:monospace;}</style></template></div></body></html>
No more JavaScript here, but same result.
Simply specifying a <template> element with the shadowrootmode attribute instructs the browser’s HTML parser that the children of that node should be part of a Shadow DOM tree.
Ehh… not great yet.
Shadow DOM itself has been around for a while and is well supported, but as of March 2023, Declarative Shadow DOM is a fresh feature that only works on Chrome.
The good news is there’s a very straight-forward polyfill you can use to add support to all modern browsers:
document.addEventListener("DOMContentLoaded",()=>{polyfill();});functionpolyfill(){// Polyfill Declarative Shadow DOM// https://developer.chrome.com/articles/declarative-shadow-dom/#polyfill(functionattachShadowRoots(root){root.querySelectorAll("template[shadowrootmode]").forEach((template)=>{constmode=template.getAttribute("shadowrootmode");constshadowRoot=template.parentNode.attachShadow({mode});shadowRoot.appendChild(template.content);template.remove();// Recursive, so you could have a DOM in your DOM in your DOM in your DOM!attachShadowRoots(shadowRoot);});})(document);}
Overall, using this approach cut down a fair bit of complexity with embedding HTML emails on Mailgrip.
Although the polyfill still requires client-side JavaScript to work, I’m looking forward to wider browser support so I can start embedding emails without any client-side code at all.
As part of my recent work on building Mailgrip, I decided to experiment with using Gumroad to manage payments and subscriptions. This post documents the minimum viable Gumroad integration I implemented, in case you are looking at doing the same thing!
Why Gumroad? At a bare minimum I wanted to be able to charge customers an annual subscription fee, offer free trials, and have tax handled for me. Gumroad fit the bill on all these requirements. Its marketplace and hosted landing pages were also a nice bonus.
Two landing pages… It is possible to use a Gumroad landing page as the only landing page for a product, but I wanted more flexibility in layout and portability if I wanted to switch to another provider down the line, so I ended up creating a self-hosted landing page too.
This approach led to needing to support two potential ways a customer can sign up and subscribe to Mailgrip:
Customer lands on the Mailgrip site, signs up, and then needs to subscribe on Gumroad
Customer lands on the Gumroad landing page, subscribes, and then needs to sign up on Mailgrip
Even with needing to support both flows, integrating with Gumroad ended up being simpler than similar work I’ve done with payment processors like Stripe. This largely came down to Gumroad’s API being narrower and less flexible than Stripe’s, and when working on a small side project freedom from choice can be a wonderful thing!
Implementation details
This section focuses on the core parts of Mailgrip’s Gumroad integration. Mailgrip is written in Elixir using the Phoenix framework so code samples will be in Elixir - but the underlying approach should be generic enough to be relevant to any stack.
Custom content delivery webhook
The main feature on Gumroad that drove my implementation is custom content delivery. It let me specify a custom URL to redirect customers to after they subscribe to Mailgrip, and invoke arbitrary behaviour within the system after a successful sale.
In Mailgrip, the handler for this webhook checks that the customer already has an account (if they don’t they’re asked to make one), ensures that the sale hasn’t already been claimed by another customer, and finally claims the sale for the logged in customer and marks them as having an active subscription.
Here’s a sequence diagram illustrating the flow:
… and here’s some annotated source from my implementation:
defmoduleMailgripWeb.GumroadSaleControllerdorequireLoggeruseMailgripWeb,:controlleraliasMailgrip.SubscriptionsaliasMailgripWeb.UserAuthplug(:validate_product_idwhenactionin[:new])plug(:validate_sale_with_gumroadwhenactionin[:new])# If there isn't a current user (i.e. customer is not logged in), then# redirect to the registration flow.defnew(%{assigns:%{current_user:nil,gumroad_sale:gumroad_sale}}=conn,_params)do%{purchase_email:email}=gumroad_saleconn|>UserAuth.require_authenticated_user(redirect_to:Routes.user_registration_path(conn,:new,via_gumroad:"true",email:email))end# If there is a current user (i.e. customer IS logged in), then# claim the sale on their behalf...defnew(%{assigns:%{current_user:%{id:current_user_id},gumroad_sale:%{id:sale_id}}}=conn,_params)docaseSubscriptions.get_sale(sale_id)do# Only create the subscription and claim the sale for the user# if the sale hasn't been claimed alreadynil->conn|>create_subscription()# Otherwise check if this user has claimed the sale before, and# if they have redirect them to the main page.%{user_id:^current_user_id}->conn|>redirect(Routes.inbox_path(conn,:index))# If another user has claimed the sale, show an error and redirect._sale->Logger.warn("Tried to activate a sale that's already been activated",sale_id:sale_id,user_id:current_user_id)conn|>put_flash(:error,"""
This Gumroad purchase has already been claimed.
Please contact us to get this resolved.
""")|>redirect(to:"/")endenddefpcreate_subscription(%{assigns:%{current_user:user,gumroad_sale:sale}}=conn)do# ...# Omitted the internals of this function for brevity. It includes# logic for marking the currently logged in user as# "subscribed", and marking the Gumroad sale as claimed by that user.end# Validates the sale against the live Gumroad API, and assigns it to# the request's state so it can be used later.defpvalidate_sale_with_gumroad(%{params:%{"sale_id"=>sale_id}}=conn,_opts)docaseGumroad.get_sale(sale_id)do{:ok,sale}->conn|>assign(:gumroad_sale,sale)_->Logger.warn("Tried to activate a sale that can't be found on Gumroad",sale_id:sale_id)conn|>put_flash(:error,"""
Your Gumroad purchase could not be activated.
Try clicking on the link to open Mailgrip in Gumroad again, and failing
that, please contact us to get this resolved.
""")|>redirect(to:"/")|>halt()endend# Validates that the product sent by Gumroad matches up with the product# we're expecting to receive events for.defpvalidate_product_id(%{params:%{"product_id"=>product_id}}=conn,_opts)doifproduct_id!=Application.fetch_env!(:mailgrip,:gumroad_product_id)doLogger.warn("Gumroad sent a request for an invalid product",product_id:product_id)conn|>put_flash(:error,"""
We can't find your Gumroad purchase in our system.
Please contact us to get this resolved.
""")|>redirect(to:"/")elseconnendendend
Redirect to Gumroad checkout page
When a customer signs up for from the Mailgrip site, but then needs to finalise their subscription on Gumroad, they’re presented with this screen:
On clicking the “Create subscription” button, they’re taken straight to the Gumroad checkout page with their email address already filled in:
To get this deep-link straight to the checkout page, I add the wanted and email params to my product’s URL:
Here’s the view helper I use to generate that URL:
defmoduleMailgripWeb.SubscriptionViewdodefgumroad_checkout_url(email)do"https://alexpls.gumroad.com/l/mailgrip"|>URI.parse()|>Map.put(:query,URI.encode_query(%{# Prefills the user's email"email"=>email,# Setting "wanted" takes the user straight to the checkout# page."wanted"=>true}))|>URI.to_string()endend
Handling subscription state changes
When a customer’s subscription changes in Gumroad (i.e. extension or cancellation), Mailgrip needs to know about it so it can update the customer’s access.
To handle this I create a Gumroad resource_subscription. I found the API naming here to be somewhat confusing, because a Resource Subscription has nothing to do with subscriptions to a product, but rather subscriptions to a resource’s events (e.g. sales, subscription updates, etc).
So essentially, creating a resource_subscription registers a webhook on Mailgrip that Gumroad will make a request against whenever an event you’re interested in happens.
On Mailgrip’s app startup, I start a one-off Task that ensures any events I want to subscribe to (cancellation, subscription_updated, subscription_ended, subscription_restarted) have an active subscription, and if they don’t - create one! Here’s the code:
defmoduleMailgripWeb.GumroadWebhookSubscriberdouseTaskrequireLoggerdefstart_link(arg)doTask.start_link(__MODULE__,:run,[arg])enddefrun()doLogger.info("Setting up Gumroad webhook subscriptions")wanted_subscriptions()|>Enum.each(&maybe_create_sub/1)Logger.info("Gumroad webhooks set up")enddefpwanted_subscriptionsdoevents=~w(cancellation subscription_updated subscription_ended subscription_restarted)Enum.map(events,fne->%{"resource_name"=>e,"post_url"=>webhook_url()}end)enddefpwebhook_urldoMailgripWeb.Router.Helpers.gumroad_webhook_url(MailgripWeb.Endpoint,:handle)enddefpmaybe_create_sub(%{"resource_name"=>resource_name,"post_url"=>post_url}=params)do{:ok,existing}=Gumroad.get_resource_subscriptions(resource_name)unlessEnum.any?(existing,fnrs->rs.post_url==post_urlend)doLogger.info("Creating webhook subscription",params:params)Gumroad.create_resource_subscription(params)endendend
As far as handling those events goes, the implementation looks like:
Here are a couple of videos showing the flow in action. I am happy with the customer experience - all up it should take about 30 seconds for a customer to sign up and subscribe (longer if they don’t already have credit card details on Gumroad).
(There’re HTTPS security warnings because this is running a dev build).
Customer signs up via Mailgrip, subscribes via Gumroad
Customer subscribes via Gumroad, signs up via Mailgrip
Gumroad gotchas
All up I found it pretty straightforward to get started with Gumroad, but there are still some things that surprised me about the experience. I admit there’s a heavy bias in this section around where Gumroad differs from Stripe, because Stripe’s the tool I know best!
Gumroad’s API docs are lacking
While there’s enough info on Gumroad’s API site to get started with building something, it doesn’t offer enough detail to be confident in an integration without extensive testing.
The example responses on the API docs don’t include much information about each field returned, its type, if it’s optional, etc. This leads to having to be quite defensive when building an integration.
Some resources are named inconsistently. i.e. “subscription” and “membership” seem to be used interchangeably. Webhooks are referred to as “Pings” in places, but “resource subscriptions” in others.
Having a first party OpenAPI spec published by Gumroad would go a very long way towards making this better.
There isn’t a test environment
Gumroad doesn’t have a test environment you can use as a sandbox while you’re developing your application.
This means you have to get creative with how you test things out. For me this included creating a separate product that I could use just for my development tests, and writing a Gumroad client that included built-in mocks so non-prod environments wouldn’t need to go out to the Gumroad API at all.
This also leads Gumroad to making some choices around their APIs that are a bit unintuitive. For example when you purchase your own product for testing purposes, the sale that gets generated as part of that doesn’t get returned when listing sales for your product, however it does show up if you know the sale’s ID and query for it directly. This kind of inconsistency leads to some confusion during development.
High-end pricing
Gumroad’s recently announced a significant increase to their fees. If you’re looking at integrating with them, make sure you understand the pricing first and look into competitors beforehand!
Conclusion
That covers the interesting parts of my recent integration with Gumroad. I hope this post will make your time setting up SaaS on Gumroad easier. If you’re interested in more specific details about the Mailgrip integration, reach out!
A little while back I wanted to print a poster of Jack Barker’s Conjoined Triangles of Success (as featured in the Silicon Valley TV show).
Since I couldn’t find a copy online that was high-res enough for printing, I made one! And now here it is, in case for some reason you also want to print this thing.