Notifio watches rental search pages and, with the auto-reply upgrade, sends your first message to the landlord for you. You show it once how you reply on a site, it replays that on the next listing. I have written about the 21 statuses that reply can end in and about why no language model is anywhere near the form filling.
This post is about one requirement underneath all of it, and the three places it turned out to need enforcing.
The requirement: a reply goes out as the user's own account on the rental site, so there has to be a saved login for that site. Signed out, the contact form either sits behind a login wall or accepts the message as an anonymous nobody, and neither of those is a message the user would recognise as theirs.
That is a one line rule. Enforcing it in one place is what I tried first, and it was wrong.
Layer 1: the engine, which is where the rule lives
The reply engine runs a list of gates before it opens a browser at all:
// A reply goes out as the user's own account, so a saved session on that site
// is a hard requirement, not a nicety. Logged out, the contact form either
// hides behind a login wall or accepts the message as an anonymous nobody,
// neither of which is a reply the user would recognise as theirs. Checked here,
// before any browser opens, rather than discovering it halfway through a
// submission.
if (!(await hasSession(site))) {
return {
status: 'skipped_login',
reason: `Not logged in to ${domain}. Log in to it on the Auto-reply tab to let Notifio reply.`,
};
}
Two details in there I would defend in review.
The gate sits after the "have we already handled this listing" check, and the comment says why: the session lookup reads the site's cookie store, and that is not work worth doing for a listing already dealt with. Cheap pure gates first, gates that touch disk or a browser profile last.
And the failure case of the check is not a maybe:
async function hasSession(site: Site): Promise<boolean> {
try {
return await checkLoginStatus(site.url);
} catch {
// A check that failed is not permission to carry on. "Don't know" is "no".
return false;
}
}
There is also a flag this gate deliberately ignores. Searches have a skipLogin option, for public results pages that need no account to scrape. It says nothing about being able to send a message, so it gets no exemption: no session means no reply, whatever skipLogin says.
Layer 2: the write, because a switch that can only fail is a bug
With only layer 1, everything is technically correct and the product is bad. The user opens a search, flips "Reply for me" to on, gets a green switch, and then every listing for the next week produces a skipped_login row in a ledger they have never looked at. The app said yes and then quietly did nothing.
So the API refuses the setting:
/**
* Both halves of auto-reply need a saved login on the site itself: the recording
* is made in the site's own signed-in session, and the reply goes out as the
* user's own account. The reply engine refuses without one, so the requests that
* set it up are refused here too rather than being left in a state that could
* never work. Returns null when it's allowed, or the 409 body when it isn't.
*/
async function loginGate(
siteUrl: string,
purpose: 'reply' | 'record' = 'reply'
): Promise<{ error: string; needsLogin: true } | null> {
let loggedIn = false;
try {
loggedIn = await checkLoginStatus(siteUrl);
} catch {
// A check that failed is not permission to carry on.
}
if (loggedIn) return null;
...
return {
error:
purpose === 'record'
? `Log in to ${hostname} first. The setup records you replying on it, which needs you signed in.`
: `Log in to ${hostname} first. Auto-reply replies as your own account, so it needs a saved login.`,
needsLogin: true,
};
}
409 Conflict, not 403. The request is well formed and the user is allowed to do it, the machine is just not in a state where it can be done yet, and the fix is an action the user can take in the next ten seconds.
needsLogin: true is the part the UI actually consumes. An error string gets rendered in a toast and read by nobody. A flag lets the panel put the site's own log in button directly under the message, which turns a refusal into the next step.
The purpose parameter exists because two different requests hit this, and the true sentence is different for each. Recording the flow needs the login because the recording happens inside the site's signed in session, so an unauthenticated recording captures a login wall instead of a contact form: a recipe that could never replay. Replying needs it because of whose account the message goes out as. Same check, and telling the user the wrong reason is the kind of small dishonesty that makes people stop believing your error messages.
Ordering inside the route handler
This is the bit I got wrong on the first attempt. The gate has to run before anything is written:
app.patch('/api/sites/:index', async (req, res) => {
...
// Checked before anything is written, so a refused switch leaves the rest of
// the patch unapplied rather than half-saved.
if (replyMode === 'auto') {
const blocked = await loginGate(cfg.sites[idx].url);
if (blocked) return res.status(409).json(blocked);
}
if (typeof enabled === 'boolean') cfg.sites[idx].enabled = enabled;
if (typeof name === 'string') cfg.sites[idx].name = name;
...
PATCH /api/sites/:index is a multi field patch. The renderer usually sends one field, but nothing stops it sending a rename and a mode change together, and a handler that applies fields as it walks them and then bails on the gate has renamed the search and refused the switch. One request, two outcomes, and the UI has no way to describe that. Validate, then mutate.
Layer 3: the teardown, because state goes stale by itself
The last hole. The user turns on auto-reply for three searches on one site, which is allowed because they are signed in. A week later they hit "Log out" on that site, which wipes the persistent browser profile. The three switches are still on, and they are now switches that cannot do anything.
/**
* Switch every search on a hostname back to email-only.
*
* Called when a site's session is wiped: leaving "Reply for me" on for a site
* we are signed out of would show a switch that cannot do anything. Returns how
* many searches changed so the caller can say so.
*/
function disableAutoReplyForHost(hostname: string): number {
The route returns the count, and the UI says so out loud, because silently changing a user's settings is its own kind of rude:
const autoReplyDisabled = disableAutoReplyForHost(hostname);
res.json({ ok: true, autoReplyDisabled });
It is keyed by hostname rather than by search, for the same reason the scraper's backoff is keyed by host: a session belongs to the site, so anything derived from it does too. Five searches on one rental site share one login, and they all lose it at once.
The same sweep runs when the user resets their recordings, which is the supported answer to "my details changed":
// Also switch every site back to email-only so nothing replies with a
// half-configured setup.
Is three layers not two too many?
The engine gate is the one that is load bearing for correctness. The other two are load bearing for whether the product is honest.
If I deleted the 409, nothing would break and the app would start lying to users in green. If I deleted the teardown sweep, nothing would break and the app would keep a stale promise on screen indefinitely. Both of those bugs are invisible in tests, invisible in logs, and obvious within a day of real use.
What made them easy to find in the end was asking one question of every boolean the user can set: what happens to this if the thing it depends on goes away? For a rental app whose entire value is being first to a listing, the answer cannot be "it keeps looking fine".
You can see the shape of the product on the help page, the per site coverage under alerts, and what the upgrade actually costs on pricing. The app itself is a free download for Mac and Windows.
Top comments (0)