Blog posts

Using Declarative Shadow DOM to embed HTML emails on a web page

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.

a screenshot of mailgrip showing depicting an email embedded onto the page

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.

    <div id="host"></div>

    <script>
      const hostElement = document.getElementById("host");
      const shadowRoot = 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:

  1. Create our host page, which includes a container element to host the shadow DOM id="host" in this case,
  2. Designate that element as the root of the Shadow DOM by using Element.attachShadow(),
  3. 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:

a screenshot showing a shadow dom embedded inside host dom

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.

    <div id="host">
      <template shadowrootmode="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.

(This too is a very simplified example. Check out the Chrome Developers article on Declarative Shadow DOM to learn more.)

What’s browser support like?

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();
});

function polyfill() {
  // Polyfill Declarative Shadow DOM
  // https://developer.chrome.com/articles/declarative-shadow-dom/#polyfill
  (function attachShadowRoots(root) {
    root.querySelectorAll("template[shadowrootmode]").forEach((template) => {
      const mode = template.getAttribute("shadowrootmode");
      const shadowRoot = 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.

Selling SaaS on Gumroad

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:

  1. Customer lands on the Mailgrip site, signs up, and then needs to subscribe on Gumroad
  2. 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:

CustomerMailgripGumroadMakes request to customcontent delivery URLSubscribesValidates saleWhen sale is invalidDenies accessWhen sale is validA sale can be invalid ifit can't be found on Gumroador if it's already been claimed.Validates usersessionWhen user is notlogged inRedirects to signup flowSigns up or logs inRedirects back to custom content delivery URLThe initial content delivery URLreceived from Gumroad is stored onthe session, so when the usersigns up/signs in they can be redirectedback to it to complete the flow.When user islogged inClaims saleand activatessubscriptionAccess granted!

… and here’s some annotated source from my implementation:

defmodule MailgripWeb.GumroadSaleController do
  require Logger
  use MailgripWeb, :controller
  alias Mailgrip.Subscriptions
  alias MailgripWeb.UserAuth

  plug(:validate_product_id when action in [:new])
  plug(:validate_sale_with_gumroad when action in [:new])

  # If there isn't a current user (i.e. customer is not logged in), then
  # redirect to the registration flow.
  def new(%{assigns: %{current_user: nil, gumroad_sale: gumroad_sale}} = conn, _params) do
    %{purchase_email: email} = gumroad_sale

    conn
    |> 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...
  def new(
        %{assigns: %{current_user: %{id: current_user_id}, gumroad_sale: %{id: sale_id}}} = conn,
        _params
      ) do
    case Subscriptions.get_sale(sale_id) do
      # Only create the subscription and claim the sale for the user
      # if the sale hasn't been claimed already
      nil ->
        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: "/")
    end
  end

  defp create_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.
  defp validate_sale_with_gumroad(%{params: %{"sale_id" => sale_id}} = conn, _opts) do
    case Gumroad.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()
    end
  end

  # Validates that the product sent by Gumroad matches up with the product
  # we're expecting to receive events for.
  defp validate_product_id(%{params: %{"product_id" => product_id}} = conn, _opts) do
    if product_id != Application.fetch_env!(:mailgrip, :gumroad_product_id) do
      Logger.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: "/")
    else
      conn
    end
  end
end

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:

a screenshot of the screen that redirects customers to Gumroad

On clicking the “Create subscription” button, they’re taken straight to the Gumroad checkout page with their email address already filled in:

a screenshot of the Gumroad checkout page

To get this deep-link straight to the checkout page, I add the wanted and email params to my product’s URL:

https://alexpls.gumroad.com/l/mailgrip?wanted=true&[email protected]

Here’s the view helper I use to generate that URL:

defmodule MailgripWeb.SubscriptionView do
  def gumroad_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()
  end
end

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:

defmodule MailgripWeb.GumroadWebhookSubscriber do
  use Task
  require Logger

  def start_link(arg) do
    Task.start_link(__MODULE__, :run, [arg])
  end

  def run() do
    Logger.info("Setting up Gumroad webhook subscriptions")

    wanted_subscriptions()
    |> Enum.each(&maybe_create_sub/1)

    Logger.info("Gumroad webhooks set up")
  end

  defp wanted_subscriptions do
    events = ~w(cancellation subscription_updated subscription_ended subscription_restarted)
    Enum.map(events, fn e -> %{"resource_name" => e, "post_url" => webhook_url()} end)
  end

  defp webhook_url do
    MailgripWeb.Router.Helpers.gumroad_webhook_url(
	  MailgripWeb.Endpoint,
	  :handle
    )
  end

  defp maybe_create_sub(%{"resource_name" => resource_name, "post_url" => post_url} = params) do
    {:ok, existing} = Gumroad.get_resource_subscriptions(resource_name)

    unless Enum.any?(existing, fn rs -> rs.post_url == post_url end) do
      Logger.info("Creating webhook subscription", params: params)
      Gumroad.create_resource_subscription(params)
    end
  end
end

As far as handling those events goes, the implementation looks like:

defmodule MailgripWeb.GumroadWebhookController do
  require Logger
  use MailgripWeb, :controller
  alias Mailgrip.Accounts

  def handle(conn, %{"subscription_id" => subscription_id, "user_id" => user_id}) do
    {:ok, sub} = Gumroad.get_subscriber(subscription_id)

    {:ok, _} =
      Accounts.get_user_by_gumroad_id!(user_id)
      |> Accounts.update_user_gumroad_details(%{
        gumroad_subscription_status: sub.status
      })

    conn |> json(%{"status" => "ok"})
  end
end

Bringing it together

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!

PDF: The Conjoined Triangles of Success

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).

Silicon Valley TV show screengrab

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.

Download the PDF (it’s about 4.1MB)

Silicon Valley TV show screengrab

Enjoy and use wisely!

← Prev Page 1 of 4