Notifio watches rental search pages and emails you when a new listing appears. Phrase the failure mode out loud and the design problem states itself: if the app breaks, you get no email, and no email is also what a quiet Tuesday looks like.
That is the worst property a monitoring product can have. A user who hears nothing cannot tell whether they are being served or ignored, and the first time they find out is when they discover a room they should have heard about days ago.
So the app has four kinds of outbound email, and two of them exist purely to report that it has stopped doing its job:
interface NotifyPayload {
email: string;
token: string;
type?: "listings" | "auth_failure" | "blocked" | "reply";
alerts?: AlertEntry[];
authFailureSite?: string;
blockedSite?: string;
reply?: ReplyNotice;
}
All four go through one endpoint with one set of rules about throwing. This post is about the two in the middle, and mostly about restraint.
What a failure email is allowed to say
The temptation is to write "an error occurred while checking Pararius". That tells the user that something is wrong and nothing about what to do, which converts a working product into an anxious one.
The app runs on the user's own machine using their own logged-in sessions, so when a session expires, the fix is genuinely theirs and it is small. The email says exactly that, and nothing else. Here is the whole plain-text version:
export function buildAuthFailureText(siteName: string): string {
// The real string also carries the footer line quoted further down.
return `Notifio: Login Required\n\nYour session for ${siteName} has expired or been logged out.\nNotifio can no longer monitor this site for new listings. Please open Notifio on your computer and log in again to resume monitoring.`;
}
The blocked email is the same shape with one extra sentence, because in that case the app is already handling it and the user's intervention is optional rather than required:
Notifio has paused checks for this site and will retry automatically. To resume sooner, open Notifio on your computer and re-open the site (log in / complete the check). That refreshes the session so monitoring can continue.
"Will retry automatically" is doing real work in that sentence. It is the difference between an email that asks for help and an email that reports a state, and the app had better be telling the truth: behind it is a backoff ladder per host that does come back on its own.
Every one of these emails carries one line of product copy that is not about the error at all:
Getting this in Promotions? Move it to Primary so you never miss a listing.
For a product whose entire value is an email arriving quickly, which tab it lands in is a feature, and the only lever we have on it is asking.
Rule one: never ask someone to log in for a logout they just performed
The app lets you log out of a monitored site from inside it. The next poll then finds a logged out session, correctly identifies it as an auth failure, and emails you to tell you to log in. Thirty seconds after you deliberately logged out.
// After a manual logout we suppress auth-failure alerts for that host for a
// while, so the user doesn't get an immediate "login required" email/notification
// for a logout they just performed on purpose. Keyed by hostname → expiry ms.
const _authSuppressedUntil: Record<string, number> = {};
const AUTH_SUPPRESS_MS = 60 * 60 * 1000; // 1 hour
Keyed by hostname rather than by search URL, because the session belongs to the site and a logout from one search logs out all of them. The suppression is lifted early by the event that proves it is no longer needed:
// A successful login lifts any post-logout auth-alert suppression for the host.
delete _authSuppressedUntil[host];
This is the kind of rule that only exists because somebody used the thing. No test would have found it, because nothing was broken.
Rule two: one email per site per hour, at most
const lastAlert = _lastAuthFailures[siteUrl] || 0;
// Rate limit: only alert once per hour per site for auth failures
if (now - lastAlert < 60 * 60 * 1000) {
return;
}
_lastAuthFailures[siteUrl] = now;
The poll cycle runs continuously, so an expired session is not one event, it is an event per cycle until it is fixed. Without this, "your session expired" becomes dozens of identical emails in an afternoon, and the user does the only reasonable thing, which is to make a filter that deletes all of them. Including the one that says a room is available.
The per site keying matters too. Five searches on five portals that all fail at once should produce five different emails, because they need five different fixes. Five emails about the same portal are spam.
Rule three: a challenge that clears on its own was never worth an email
This is the one I would not have written in the first version.
// A challenge that clears on the next try is not worth an email. Only tell
// the user once it has survived a couple of retries, because by then it needs
// them to go and pass the check by hand.
if (kind === 'challenge' && entry.failures < 3) return;
An interstitial verification check is often transient. It appears, the next attempt a minute later goes through, and the user never needed to know. A flat refusal or an explicit rate limit is different and emails on the first occurrence, because those do not clear themselves.
The number is three, which means the first email about a verification check arrives after the ladder has already spent its first two waits on it. By that point the app has genuinely established that it cannot get past this alone, which is the only state worth interrupting someone for.
The window hears about it immediately, through a slightly embarrassing seam
Email is for when the user is not looking. When the window is open it gets the same news instantly, and the mechanism is a marker in the log stream:
// Tell frontend via SSE (server.ts intercepts this specific log format)
log(`__AUTH_FAILURE__:${siteUrl}`);
monitor.onLog((line) => {
if (line.includes('__AUTH_FAILURE__:')) {
const url = line.split('__AUTH_FAILURE__:')[1].trim();
broadcast({ type: 'auth_failure', url });
return; // Don't add to visible log buffer
}
// ...
});
This works and it is not what I would design twice. The monitor's only generic outbound channel was its log callback, so the first cross-process signal borrowed it, and the sentinel string exists so the UI can act on a log line the user is never shown. The right answer is the typed event channel that arrived later and now carries the per search runtime state, where blocked and auth_failure are first class states rather than strings in a log.
I am leaving it in the post because an honest answer to "how does your UI learn about a block" is more useful than a tidy one, and because the lesson generalises: the first consumer of a channel decides its type, so a log stream will accumulate control messages unless something else is available.
What this looks like from outside
The sites where this matters most are the ones that only show results to a logged-in session, which is why the per site pages have a field for exactly that. notifio.app/alerts/kamernet and notifio.app/alerts/wg-gesucht say whether a login is needed before you buy anything, rather than after.
notifio.app/help covers what to do when one of these two emails arrives, the app is at notifio.app/download, and the licence is a one-time purchase at notifio.app/pricing.
Top comments (0)