Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. It runs unattended for weeks at a time, which means most of its interesting states are ones the user is not looking at when they happen.
The monitor reports a status. The status is an enum: running, stopped, polling, not_activated, license_invalid. That is enough for a coloured dot and a word in the window, and for a long time it was all there was.
Here is the comment that got added when that stopped being enough:
/**
* The one thing the user needs told, if anything.
*
* Added because "License invalid" appeared in the UI with no reason given and
* no way to tell a rejected licence from a licence we simply could not check.
* A status is a single word; this is the sentence that goes with it.
*/
let _notice: Notice | null = null;
Two different events produced the same word. The server said the licence does not exist, or the server could not be reached at all. One of those means go and re-activate, and the other means wait for your wifi. The user saw "License invalid" for both and reasonably concluded they had been robbed.
I wrote about telling those two apart in the licence check itself in A 429 is not a no: the three answers our licence check has to tell apart. This post is about what happens to that distinction after it has been made, which turns out to be a separate problem.
The channel
export interface Notice {
level: 'info' | 'warn' | 'error';
text: string;
at: number;
}
Three fields. The at is there because a notice can outlive the event that produced it by a long way in a tray app, and a sentence with no timestamp is a sentence the user cannot judge.
function setNotice(level: Notice['level'], text: string): void {
if (_notice?.text === text && _notice.level === level) return;
_notice = { level, text, at: Date.now() };
_noticeListeners.forEach((fn) => fn(_notice));
}
function clearNotice(): void {
if (_notice === null) return;
_notice = null;
_noticeListeners.forEach((fn) => fn(null));
}
Both setters are guarded against saying the same thing twice. That is not a micro-optimisation, it is the whole point: the cycle that discovers "could not reach the server" runs every thirty seconds and will discover it again, and again, for as long as the wifi is off. Without the early return, a user who closed their laptop lid in a cafe comes back to a notice whose timestamp is always five seconds old, which reads as a brand new failure rather than an ongoing one.
The subtler guard is in the licence path:
if (result.ok) {
_nextLicenseCheckAt = Date.now() + LICENSE_TRUST_MS;
if (_notice?.level === 'warn') clearNotice();
log('[monitor] Licence confirmed');
return true;
}
A successful check clears a warning and does not clear an error. Warnings in this app are transient conditions that resolve themselves, so the success is the all-clear. Errors are states that stopped the monitor and need the user to do something, and a background success has no standing to dismiss a message the user has not read.
That asymmetry is also why being unable to reach the server is a warning and not an error:
/**
* Being unable to reach the server is not a reason to stop. The licence is
* checked again on every alert by the server that sends it, so monitoring on
* with an unverified licence cannot get anyone alerts they have not paid for;
* it just means a laptop that woke up before its wifi did keeps looking for
* rooms instead of shutting down and blaming the user's licence.
*/
Six hours of trust after a confirmation, five minutes of retry after a failure, and _nextLicenseCheckAt = 0 on every start() because the user has just told us to go and the last answer may be hours old.
The notice goes out as its own message type on the local event stream, alongside the status and the per-search state, and it is replayed to a client that has just connected. The window reloads itself whenever it is re-shown, so a notice that only existed as a live event would be invisible to everyone who was not watching at the time.
// The sentence that goes with the status, when there is one to say.
monitor.onNotice((notice) => broadcast({ type: 'notice', notice }));
Three maps for staying quiet
The notice is for the window. Blocks and expired sessions also produce an email and a desktop notification, and those are the ones that can genuinely hurt. A poll cycle that fails every thirty seconds can produce 2,880 of anything in a day, a problem I wrote about from the email side in Every thirty seconds it fails, and you should get one email, not 2,880.
The monitor ended up with three separate maps controlling this, and the interesting thing about them is that they are keyed differently on purpose.
let _lastAuthFailures: Record<string, number> = {}; // track to avoid spamming
let _lastBlockAlerts: Record<string, number> = {}; // hourly rate-limit for block notifications
// 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
The first two are keyed by search URL. The third is keyed by hostname. That is not an inconsistency.
An expired session and a block are both facts about the host, but the alert is about the search, because the search is what the user named and what they will go and look at. Two searches on the same portal that both lose their session are two things the user has to fix, in two different places in the UI, so they are two alerts.
A manual logout is different. The user clicked "log out" in the built-in browser once, for a host, and the thing being suppressed is not an alert about a search but a class of alert about a site. Keying that by search URL would mean a user who logs out of a portal they watch with five searches gets four "login required" alerts suppressed and one delivered, depending on which URL happened to be the one they were looking at when they logged out.
export function suppressAuthAlert(siteUrl: string): void {
_authSuppressedUntil[hostOf(siteUrl)] = Date.now() + AUTH_SUPPRESS_MS;
}
And the symmetric part, which is easy to forget and produces a genuinely confusing bug if you do:
// A successful login lifts any post-logout auth-alert suppression for the host.
delete _authSuppressedUntil[host];
Without that line, a user who logs out, changes their mind, logs back in, and then has their session actually expire forty minutes later gets told nothing. The suppression has to end when the condition that justified it ends, not when its timer runs out.
The same pattern shows up in the backoff reset, which clears the block-alert history for every search on the recovered host:
function resetBackoff(host: string): void {
_backoff.clear(host);
delete _hostErrors[host];
for (const key of Object.keys(_lastBlockAlerts)) {
if (hostOf(key) === host) delete _lastBlockAlerts[key];
}
}
A host that recovered and then got blocked again an hour later is news. The loop over keys is there because the map is keyed by URL and the event is per host, which is the price of the two different keyings above and worth paying in one place rather than losing the distinction everywhere else.
The rule that is not a rate limit
The hourly cap stops repetition. It does not stop the first alert from being wrong, and this is the one that actually improved the product:
// 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;
Most interstitials clear themselves. By the time one is recorded, the scraper has already tried to read past it and tried waiting it out, and the next cycle usually sails through. An email for every one of those teaches the user that Notifio emails are noise, which is an expensive thing to teach them when the entire product is an email.
Three consecutive failures of the same kind on the same host is the threshold at which the thing genuinely needs a human to go and click something. Note it checks entry.failures, the counter owned by the per-host backoff registry, rather than keeping a fourth counter of its own.
One honest hack
The monitor tells the UI about blocks and auth failures by emitting a log line in a magic format:
// Tell the frontend via SSE (server.ts intercepts this specific log format)
log(`__BLOCKED__:${siteUrl}`);
And on the other side:
broadcast({ type: 'blocked', url });
return; // Don't add to visible log buffer
This is a side channel smuggled through the logging function, and it is the one thing in this part of the codebase I would not defend on design grounds. It predates the per-search state store, which is now the right place for this, and the only reason it survives is that the interception is three lines in one file and the alternative is another listener API for two events. The return at least keeps the magic string out of the user-visible activity log, so the hack does not leak into the product.
The counterpart that is not a hack is the rules keeping the two "we stopped working" emails from becoming noise, which I covered in Two of our four emails exist only to say we stopped working, and three rules keep them from becoming noise.
Go and look
The help page has the user-facing side of all of this: what the "Login Required" email means, which sites it happens on, and what to do about it. Facebook Marketplace is the site where you will meet the auth path, since it drops sessions after a period of inactivity, and Idealista is where you will meet the challenge path. The app is on the download page for macOS and Windows, and the pricing page is one payment with no subscription, which is why a licence check that cannot reach the server is allowed to carry on rather than lock the app.
Top comments (0)