Notifio watches rental listing sites and tells you the moment something new is posted. On the upgraded tier it can also send the first enquiry for you, by replaying a demonstration you performed once on that site's own contact form.
That feature has exactly one unforgivable failure. It is not "the reply did not go out". It is messaging the same landlord twice, from your own account, about the same room. One of those is a missed listing. The other is you looking like a bot to the person you are trying to impress.
So the reply engine keeps a ledger, and almost every interesting decision in it is about what counts as "already dealt with".
An NDJSON file, not a database
/**
* Append-only NDJSON ledger of reply attempts.
*
* This is not a history feature. It exists so the reply engine can answer two
* questions across restarts: has this listing already been messaged, and how
* many replies have gone out recently. Found listings are not recorded at all,
* the alert email is the record of those.
*/
The obvious choice is SQLite. The reason it is not SQLite is that Notifio is an Electron app that already ships a bundled Chromium for its own browser automation, and the electron-builder configuration that makes that work across two macOS architectures took long enough the first time. I wrote about that build in Two DMGs, because a universal build would ship two Chromiums. Adding a native module to that pipeline buys transactions I do not need for a file that holds a few thousand rows.
What I do need is writes that cannot half-happen. Append-only NDJSON gives that for free: one appendFileSync of one line, and readers fold the rows last-wins.
/** Fold append-only rows into current state, last write wins. */
function fold<T extends { id: string }>(rows: T[]): Map<string, T> {
const byId = new Map<string, T>();
for (const row of rows) {
if (!row || typeof row.id !== 'string') continue;
byId.set(row.id, { ...byId.get(row.id), ...row });
}
return byId;
}
An update is not an edit, it is a new row carrying the same id. There is no read-modify-write window in which a crash leaves a corrupt record, because no record is ever modified. The reader also skips lines it cannot parse, which covers the one genuinely likely corruption: a torn final line from the laptop lid closing mid-append.
21 statuses, and 8 of them mean stop
A reply can end in a lot of ways. The status union has 21 members, including skipped_offsite, skipped_paywall, skipped_captcha, skipped_quota and skipped_recipe_broken, because the engine refuses far more often than it sends and the user deserves to know which refusal happened.
Only eight of them close the listing for good:
/**
* Statuses that mean "this listing has been dealt with, never touch it again".
* `failed` is deliberately included: a submit that errored may still have gone
* through, and messaging a landlord twice is worse than missing one.
*/
const TERMINAL_STATUSES: ReadonlySet<ReplyStatus> = new Set<ReplyStatus>([
'queued',
'in_progress',
'awaiting_approval',
'sent',
'probably_sent',
'failed',
'needs_review',
'duplicate',
]);
The list reads oddly until you notice what it is really asking. It is not "did this succeed". It is "is there any chance a message left this machine for this listing". Under that question:
-
failedbelongs. A submit can throw after the POST went out. The site's confirmation page is the only thing that would tell us otherwise, and we did not get one. -
in_progressbelongs. A crash mid-reply leaves a row in that state forever, and the safe reading of a row stuck atin_progressis that the form may already be gone. -
probably_sentis its own status for the same reason. The form submitted and no confirmation marker appeared, so the record says so rather than rounding up tosentor down tofailed. -
queuedbelongs, which is the only one that is purely about not racing yourself within a cycle.
Everything beginning skipped_ is absent, because a skip means nothing was typed. Those listings stay eligible: fix the login, or redo the recording, and the next cycle can still reach them.
export function isListingHandled(listingId: string): boolean {
const existing = getReplyForListing(listingId);
return existing ? TERMINAL_STATUSES.has(existing.status) : false;
}
The same rows answer a second question differently
The ledger's other job is enforcing the rate limits, and those deliberately do not use the same set:
const statuses = new Set<ReplyStatus>(
options.statuses ?? ['sent', 'probably_sent', 'in_progress', 'awaiting_approval']
);
failed counts as handled but does not count against your daily allowance. duplicate and needs_review are the same. Both sets err in the direction of sending less, but they err about different things: the idempotency check is generous about what might have gone out, and the quota check is strict about what actually did.
Having one function with a statuses parameter rather than two hardcoded readers is what made that distinction visible. When they shared a constant, the disagreement was a bug waiting to be discovered by someone whose failed reply burned a slot.
Compaction is the same append, once
const REPLIES_MAX_ROWS = 8000;
const REPLIES_KEEP = 2000;
Past 8,000 rows the file is folded, the newest 2,000 records are written to a temp file, and the temp file is renamed over the original. Rename is atomic on every platform the app ships to, so a reader either sees the whole old file or the whole new one.
2,000 records is well beyond the rolling windows the limits use, which are hours and days rather than months. The ledger is not a history feature, and a file that answers "recently" does not need to remember last spring.
What is not in it
No listing is written to this file unless a reply was attempted. The rooms Notifio finds for you go into the alert email and the in-app activity list and nowhere else, and the enquiry text itself lives in the recording on your machine, never on a server. That is less a privacy feature than a consequence of where the work happens: the scraping, the diffing and the form filling all run on your laptop, and the hosted side only sends the email.
If you want to see the shape of the thing this ledger is protecting, the auto-reply upgrade is on the pricing page, the setup flow is documented in notifio.app/help, and the portal pages it runs against are listed at notifio.app/alerts.
Related, from the same app: Most of our diff code exists to not send an alert is the same instinct one layer up, where the decision is whether a change is worth telling you about at all.
Top comments (0)