DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our UI says "Invite sent" and the function that sends it cannot fail

Transactional email is the only output of a web app that renders in software you
cannot test, cannot instrument and did not choose. Everything else in PubTrivia
I can open in a browser and measure. The invite email goes into a client I have
never seen, on an account I do not control, and the only signal that comes back
is whatever the provider tells me, assuming anything is listening.

Here is the whole module, as a set of decisions rather than a tutorial, because
the interesting ones are at the edges.

One function, two bodies

const { error } = await getResend().emails.send({
  from: FROM,
  to,
  subject: `You've been invited to join ${venueName} on PubTrivia`,
  html: inviteEmailHtml({ venueName, acceptUrl }),
  text: inviteEmailText({ venueName, acceptUrl }),
})
Enter fullscreen mode Exit fullscreen mode

The text part is not a courtesy. A message with an HTML body and no plain text
alternative is a spam signal, and plenty of readers are seeing the text part on
purpose: notification previews, watches, screen readers, clients configured to
prefer it. So there are two template functions, and the words exist twice.

That duplication is the standing cost of the design, and it is a real one. Every
sentence in the email has two homes, nothing compares them, and no test asserts
they say the same thing. The reason I have not factored out a single source of
copy is that the two bodies do not have the same shape: one needs a link as a
styled block element and the other needs the URL printed as text on its own
line, so the "shared" version would be a small template language and I would
rather have the duplication than the language. That trade is fine as long as you
know you made it. Writing it down here is most of the point.

The markup is from 2004 and that is correct

<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 16px;">
  <tr>
    <td align="center">
      <table width="100%" style="max-width:520px;background:#18181b;border-radius:12px;padding:40px;border:1px solid #27272a;">
Enter fullscreen mode Exit fullscreen mode

Nested tables, cellpadding, every style inline. No stylesheet, no classes, no
flexbox, no custom properties. This is not nostalgia, it is the subset that
survives the journey: a <style> block may be stripped, inline styles on table
cells are the part every client has agreed on for twenty years.

The palette is lifted straight from the app, which is dark: #09090b behind the
message, #18181b for the card, #27272a for its border, #fafafa for the
heading and a light button with dark text. The email looks like the product,
which is what you want when the recipient is being asked to trust a link.

The risk I have not measured, and will not claim I have, is dark mode in the
clients that implement it by transforming colours themselves. An email that is
already dark can come out of that transform looking worse than one that was
light to begin with. That is a real possibility in this design and the honest
status is that I have checked it in exactly two clients, which is not a test.

One value, three escaping paths

The venue name is user supplied: whatever the owner typed when they set up their
venue. It appears in three places in one send() call, and each place has a
different rule.

In the subject line it is interpolated raw, because a subject is not markup. In
the plain text body it is interpolated raw, for the same reason. In the HTML body
it goes through this:

function escHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}
Enter fullscreen mode Exit fullscreen mode

Four replacements, ampersand first, which is the one ordering that matters: swap
it later and you re-escape the ampersands you just introduced.

What is missing is the apostrophe, and the interesting part is that its absence
is currently harmless. The escaped value only ever lands in element text
content, next to a heading and inside a paragraph, and the attributes in that
template are double quoted. An unescaped ' cannot break out of either. So the
function is correct for the two contexts it is used in.

I still want the fifth replacement, and the reason is the function's name.
escHtml reads like a general purpose helper, so the next person to need
escaping, including me in four months, will reach for it and may well drop the
result into a single quoted attribute. A helper that is only safe in the places
it happens to be called today is a trap with a reassuring name. Either escape
the apostrophe too, or rename it to say what it actually guarantees.

The send cannot fail, on purpose

if (error) {
  // Log but don't throw. A failed email shouldn't roll back the invite row.
  console.error('[invite-email] failed to send invite to', to, error)
}
Enter fullscreen mode Exit fullscreen mode

I stand behind the decision. The database row is the fact: this person has been
invited, with a token, to this venue. The email is a notification about that
fact. If the mail provider is down for ninety seconds, the right outcome is an
invite that exists and a notification that did not arrive, not an invite that was
rolled back because a third party had a bad minute.

The problem is what the caller does with that. The server action inserts the row,
awaits the email, and then:

await sendInviteEmail({ to: email, venueName, token })

revalidatePath('/dashboard/settings')
return { success: true, message: `Invite sent to ${email}.` }
Enter fullscreen mode Exit fullscreen mode

sendInviteEmail returns Promise<void> and swallows its own errors, so
success: true is unconditional and the toast says "Invite sent to
someone@example.com" whether the provider accepted it, rejected it, or was never
reached. The design decision was "email failure must not fail the invite". The
implementation turned that into "email failure must not be mentioned", which is
a different and worse thing.

Two paragraphs away, the comment in the email module says the owner can see the
pending invite and resend manually. I went looking for that path while writing
this, and it does not exist. The actions file exports exactly four functions:
list members, invite, revoke, accept. There is no resend. And the invite action
rejects a second attempt to the same address while a row is pending, with "An
invite has already been sent to this email", so the recovery for an email that
never arrived is revoke and re-invite. The comment described the mitigation I
assumed existed rather than one I had written, which is the most common kind of
wrong comment there is.

The data to fix it is already arriving and being thrown away

The other half of this is already wired up. The app has a webhook for the mail
provider's events, and its switch statement reads:

case 'email.delivered':
case 'email.bounced':
case 'email.delivery_delayed':
case 'email.complained':
  break;
Enter fullscreen mode Exit fullscreen mode

Four event types, received, verified, and deliberately dropped. Every piece of
information needed to tell an owner "this invite bounced" or "this invite was
delivered nine seconds ago" arrives at that endpoint and is discarded, because
when I wrote the handler there was nowhere to put it.

So the fix is smaller than it looked when I started writing. Give
sendInviteEmail a return value, so the action can distinguish invited and
notified from invited and not notified. Add a resend action for pending rows.
Store the last delivery event against the invite, which the webhook can already
do. None of that changes the decision that a mail failure must not roll back a
row. It only stops the UI claiming something it does not know.

Where to look at the thing being described

The feature all of this serves is on
multiple hosts and venues: a venue
owner invites other people to host quiz nights, each invite being a pending row
until it is accepted. The free tier on
the pricing page needs no card, so the fastest
way to see this email is to sign up, open the team panel in settings and invite
a second address you own. Watch the toast, and then notice that the toast is the
only thing that ever tells you anything.

If you want one line out of this: a notification that cannot fail is a good
design, and a user interface that cannot report failure is a bug wearing the same
coat.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.