Notifio can answer a rental listing on your behalf by replaying a flow you recorded once. The risk everyone expects is that the form changed. The risk that actually keeps me honest is navigation.
Rental aggregators do not always own the listing. You click "respond" and you are on a completely different company's site, where you are invited to create an account, confirm an email, and pay for a membership before anything can be sent. Automating a flow onto a page like that would mean the app signing a user up to a third party and reaching a payment screen on their behalf. Nothing about the consent the user gave covers that.
So the first rule in the auto-reply engine is a domain lock, and the whole rule is one comparison. Which sounds trivial, and the three obvious ways to write it are all wrong.
Three wrong comparisons
Hostname equality. new URL(a).hostname === new URL(b).hostname refuses too much. Portals move between www.example.nl and example.nl inside a single session. They serve en.example.nl for English, m.example.nl on old mobile paths, and sometimes a city subdomain. All of those are the same company, the same session, the same listing. A hostname check turns ordinary behaviour into a refusal, and a guard that refuses ordinary behaviour gets switched off.
endsWith. candidate.endsWith("example.nl") accepts notexample.nl, and anyone who registers fake-example.nl owns your guard. This is the classic one, and it is still in production code everywhere.
includes. Worse than endsWith in every way. includes("example.nl") accepts example.nl.attacker.com, which is a hostname somebody else fully controls.
What you actually want is the registrable domain: the part of the hostname somebody had to buy. That is the public suffix plus one label, eTLD+1, and you cannot compute it from the string. .nl is a suffix, .co.uk is a suffix, and so is .pvt.k12.ma.us. It needs the list.
import { getDomain } from 'tldts';
export function registrableDomain(url: string): string | null {
try {
return getDomain(url, { allowPrivateDomains: true }) ?? null;
} catch {
return null;
}
}
The option in the middle of that call
allowPrivateDomains is the part worth a post of its own.
The Public Suffix List has two sections. The ICANN section is the real registry suffixes: com, nl, co.uk. The PRIVATE section is the one companies added for themselves: github.io, vercel.app, blogspot.com, regional S3 and storage hosts, white-label platform domains. It exists because two GitHub Pages sites are genuinely unrelated parties who happen to share a parent name.
With the private section off, a.somehost.io and b.somehost.io both reduce to somehost.io, so they look like one site. With it on, they reduce to themselves, so they look like two.
Most of the time off is the default you want. Grouping subdomains under their owner is right for analytics, for a rate limiter counting a customer's traffic, for a cache key. Those systems want to know who, and "who" is the account that bought the name.
Inside a guard the calculus inverts, because the two errors are not comparable. A false "different site" costs one reply that the user can send by hand in thirty seconds. A false "same site" costs an automated submission on a stranger's property. So the guard takes the option that splits more things apart, and the comment in the file says so plainly: it is on deliberately, because it is the strict direction.
The null branch is the same reasoning. If either URL fails to parse, the answer is no:
const a = registrableDomain(origin);
const b = registrableDomain(candidate);
if (!a || !b) return false;
if (a === b) return true;
return allowlist.some((d) => d.toLowerCase() === b);
An unparseable URL is not a neutral event. It is the input least likely to be what it claims.
The allowlist, and why it is not a setting
Some sites genuinely do need to cross a domain. A portal that rebranded from .nl to .com runs both for years. A few use a white-label messaging host for the contact form itself, so the form legitimately posts somewhere else.
That is handled by a curated allowlist, shipped as data, and the important property is the one it does not have: it is not editable by the user.
The temptation is obvious. An allowlist in a settings screen is one line of UI and it makes every awkward site somebody else's problem. It is also exactly the wrong feature. The guard exists to protect a user from a page that is trying to take them somewhere, and a user who can be talked into clicking "respond" on an aggregator can be talked into pasting a domain into a text box. A protection you can be persuaded to disable protects you from accidents and not from anything else.
The same reasoning keeps the auto-reply safety numbers out of the settings screen entirely. If a value exists to protect the user's accounts on somebody else's site, we should be picking it and shipping it, not asking.
The rest of the gate
The domain lock is the first of seven deterministic checks that run before anything is typed. The others look for a login wall, a signup wall, a paywall, a CAPTCHA, payment-provider markers in the HTML, and the broader question of whether the page is a listing at all rather than a blog post the link sweep picked up. Every one of them is regex and status codes. None of it is delegated to a model, because the whole value of a refusal is that it is predictable, repeatable and explainable in the reply record afterwards.
A couple of those have their own stories. The paywall detector has to tell a membership fee from rent, which comes down to digit count, and I wrote that up in €1450 per month is rent, €9.99 per month is a paywall. The consent question, which boxes get ticked for you and which never will, is in the consent checkbox we tick for you. What this post adds is the one check that runs before all of them, because there is no point reasoning about a page you should not be on.
Sites where handoffs are common are called out on their own pages, for example HousingAnywhere and Spotahome. Setup and what the app will and will not do is on the help page.
Top comments (0)