Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Four kinds of email, one endpoint, and only one caller is allowed to throw

Notifio is a desktop app that watches rental search pages and emails you the moment a new listing appears. The app runs on your laptop. The email comes from our server. Those two facts produce one of the more interesting

Notifio is a desktop app that watches rental search pages and emails you the moment a new listing appears. The app runs on your laptop. The email comes from our server. Those two facts produce one of the more interesting endpoints in the project.

The app has no mail credentials, deliberately. A Resend API key shipped inside an Electron app is a key anybody can unpack out of the bundle in about four minutes, and the first person who does gets to send mail as notifio.app until our domain reputation is gone. So the desktop app cannot send email. It asks.

// Outbound notifications to the Notifio server, which owns the email sending.
//
// The app never talks to an SMTP provider itself. It posts to /api/notify with
// the licence, and the server decides what to send.

What makes the endpoint worth writing about is that four quite different situations go through it, and the four callers have four different opinions about what a failure means.

Four kinds of email, one route

The payload carries a discriminator, and every branch validates its own required fields before it does anything:

const type = payload.type || "listings";

if (type === "auth_failure") {
  if (!payload.authFailureSite) {
    return NextResponse.json(
      { error: "authFailureSite is required for auth_failure type." },
      { status: 400 }
    );
  }
  // ...
}

The four:

  • listings is the product. New rooms were found, here they are.
  • auth_failure means a site we were checking has logged us out, so the monitor cannot see anything until the user signs in again.
  • blocked means a site is refusing automated checks.
  • reply means the auto-reply upgrade successfully messaged a landlord on your behalf.

The obvious design is four routes. The reason it is one is that this client cannot be redeployed. There are installed builds of the app on machines we will never reach, and some of them are old. A new route is a URL those builds do not know. A new type value, with payload.type || "listings" as the default, is a protocol an old build keeps satisfying forever: it just never sends the newer kinds.

That single || is the backwards compatibility hinge for every version of the app that predates the auto-reply feature.

The licence is the session

There is no login anywhere in this product. The app is a one-time purchase, activated with a token typed in by hand, so the credential on every one of these requests is the purchase email plus that token:

const [license] = await db.select().from(licenses)
  .where(eq(licenses.token, token)).limit(1);

if (!license || license.email !== email || !license.active) {
  return NextResponse.json({ error: "Invalid or inactive license." }, { status: 403 });
}

Three distinct failures, one response. No token, right token with the wrong email, and a deactivated licence are indistinguishable from the outside, so the endpoint cannot be used to find out whether an address bought the app.

Rate limiting is keyed to the licence rather than the IP, with IP only as a fallback:

const { success: withinLimit } = await ratelimit.limit(`notify:${token || ip}`);

Shared student houses were the reason, which I went through in A student house is one IP address, so our rate limit counts licences. The notify: prefix matters too: it keeps this endpoint out of the bucket /api/validate uses, so a licence check storm cannot starve the alerts that the product actually exists to deliver.

Status codes are instructions to a client you cannot patch

This is the part I would do the same way again on any API with a desktop or mobile client.

When your client is a web page, a bad status code is a bug you fix and deploy in ten minutes. When your client is an Electron app sitting in somebody's system tray with a six week old version number, your status codes are the only language you have for telling it what to do. So each one means exactly one thing here:

  • 400 means the payload is wrong. A build already on someone's laptop is sending something malformed, and retrying will produce the identical 400 forever. The route is careful to produce these rather than 500s, including for shape problems inside the array: if (!alerts.every((a) => a && typeof a.siteName === "string" && Array.isArray(a.newListings))). A malformed field must not become an exception halfway through a request.
  • 403 means the licence is the problem. Stop sending, tell the user.
  • 429 means slow down and come back. Notably not "your licence is bad", which was its own small piece of work: A 429 is not a no.
  • 502 means our mail provider failed and nothing is wrong with the request. This one is a retry invitation:
if (!success) {
  console.error("[notify] Listing alert email failed:", error);
  return NextResponse.json({ error: "Failed to send email." }, { status: 502 });
}

The distinction between 400 and 502 is the entire contract. One says never ask again, the other says ask again in a minute.

Only one caller is allowed to throw

Here is where the four types stop being symmetric. In the app, the listing alert does this:

// Email is the only remote channel. If it fails we throw, so the caller keeps
// the listings "unseen" and retries on the next poll.
const res = await fetch(`${serverUrl}/api/notify`, { /* ... */ });

if (!res.ok) {
  const body = await res.text();
  throw new Error(`Server returned ${res.status}: ${body}`);
}

Throwing is the feature. If the alert email did not go out, the monitor must not record those listings as seen, because recording them means the user never hears about that room from any channel, ever. The listings stay unseen, the next poll finds them again, and the email is attempted again.

The other three callers do the opposite:

try {
  await fetch(`${serverUrl}/api/notify`, { /* type: 'auth_failure' */ });
  log(`[notify] Auth failure email sent for ${siteName}`);
} catch (err) {
  // swallowed
}

And the reply notice says why in its own doc comment:

/**
 * Never throws: the reply itself has already happened by the time this runs, so
 * a failed email must not turn a successful send into a logged failure.
 */

That is the rule underneath all four. An email that is the outcome has to be retried until it lands. An email that merely reports an outcome which already happened must never be allowed to rewrite it.

Both kinds also carry a 15 second timeout (AbortSignal.timeout(15_000)), because a poll cycle is a queue of searches and a hanging notification is a search that goes stale.

Three of them are deduped before they are ever sent

The two status emails are the ones most capable of being obnoxious, so the app keeps per-search timestamps and will not send the same kind of warning about the same search inside its cooldown:

let _lastAuthFailures: Record<string, number> = {}; // track to avoid spamming
let _lastBlockAlerts: Record<string, number> = {};  // hourly rate-limit for block notifications

Plus a nice small one: if the user has just logged themselves out of a site on purpose, the "you need to log in" email is suppressed entirely. There is no point emailing somebody about a consequence of something they did thirty seconds ago.

The general version of that, on a different app with a failure every thirty seconds, is in Every thirty seconds it fails, and you should get one email, not 2,880. What this post adds is the endpoint on the other side of it, and why its four callers disagree about failure.

The rule worth taking away

Decide for every notification whether it is the outcome or a report of the outcome, and let only the first kind fail loudly. Then make sure your status codes say "never retry" and "retry" as clearly as you would want them to if you could not ship a client fix for six weeks, because quite often you cannot.

See it working

The app is on notifio.app/download for Mac and Windows, and what the alert email actually looks like when a room turns up is covered on notifio.app/help.

For the part this endpoint is competing with, the per-portal pages under notifio.app/alerts go through what each rental site's own notification pipeline has to do before its email reaches you, and notifio.app/guides/how-to-be-first-to-a-rental-listing is the version of that argument without any code in it.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.