Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. Almost everything it knows stays on the machine it runs on, which is the main thing the privacy policy is there to say.
Section 2 of that page is a list of eight bullets under the heading "Data Stored Only On Your Device". I have audited the other direction before, checking what the app sends us against what the policy says it sends. This time I went bullet by bullet through the local list with the code open next to it.
Six of the eight are accurate. The other two describe stores that no longer exist. One sentence promises something a deleted code path used to do. And two files that are genuinely on disk are not in the list at all, including the one that records which rental sites you have logged into.
None of this is a leak. Nothing in here leaves the machine, which is the substantive promise and it holds. What is wrong is the description, and a privacy policy is a description.
The eight bullets
Here is the list as published, in order, with what I found:
| Bullet | Verdict |
|---|---|
| The search URLs you add to monitor | correct, config.json |
| Browser session cookies and the browser profile | correct, browser-data/ |
| Snapshots of listing results | correct, data/<sha1>.json |
| Activity log lines | names the wrong artifact, twice |
| Your reply profile, with ten named categories of personal data | the store was deleted |
| The message you send to landlords | no longer a separate store |
| Recorded reply flows, "recorded values are masked" | the masking has no callers |
| A short record of each reply attempt | correct, ledger/replies.ndjson |
The locations all come from one module, which makes this kind of audit tractable:
// app/src/paths.ts export const DATA_DIR = path.join(USER_DATA_DIR, 'data'); export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data'); export const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json'); export const PROFILE_PATH = path.join(USER_DATA_DIR, 'profile.json'); export const ENTITLEMENT_PATH = path.join(USER_DATA_DIR, 'entitlement.json'); export const RECIPES_PATH = path.join(USER_DATA_DIR, 'recipes.json'); export const LEDGER_DIR = path.join(USER_DATA_DIR, 'ledger'); export const FINDS_PATH = path.join(USER_DATA_DIR, 'finds.json'); app.getPath('userData') is %APPDATA%\Notifio on Windows and ~/Library/Application Support/Notifio on macOS. I wrote about the durability strategy behind each of those files a while back. This post is a different question about the same folder: does the page we publish about it match it.
Bullet 5: a store we deleted
The policy says this, at length and in bold:
Your reply profile, the details you enter for Auto-Reply to submit, which may include your name, email, phone number, date of birth, nationality, occupation, employer, income, household size, and any custom fields you add yourself
There is no reply profile. The module that used to hold it now opens like this:
/** * The auto-reply on/off switch. That is the entire settings surface. * * Replaces the old profile store, and then the shared message: neither is needed, * because a recording already contains everything that site's form asks for, * message included. Nothing about a reply is configured centrally any more. */ const DATA_VERSION = 4; function defaults(): UserData { return { version: DATA_VERSION, autoReply: { enabled: false } }; } profile.json still exists, still goes through the OS keychain, and now contains one boolean. The migration that got it there only deletes, by design, so a user who filled in that profile two versions ago no longer has it on disk either.
The bullet below it, "The message you send to landlords", went the same way for the same reason.
That leaves a sentence further down the section doing less work than it reads:
Your reply profile is encrypted at rest using your operating system's own credential store (Keychain on macOS, DPAPI on Windows), so it is not readable as plain text on disk.
True, and it is now a sentence about a boolean. The file that holds the personal data today is recipes.json, which goes through the same encrypted envelope, and the policy's encryption sentence does not mention it.
Bullet 7: masking with no callers
This is the one worth the whole audit:
The reply flows you record for each website, including the form fields involved. Recorded values are masked, so these files do not contain your personal details
Two code comments say the same thing. The recorder's preload:
/** * Raw typed values are sent to main only so it can work out what each field * means by matching against the saved profile. They are never written to disk; * recipes store a masked preview. */ And the store itself:
/** * Stored as plain JSON (no PII: recorded values are masked at capture time) so * recipes stay portable and can eventually be shipped as curated data for * popular sites. */ The function they are describing is real, and it is good:
export function maskValue(value: string): string { const trimmed = value.trim(); if (trimmed.length === 0) return ''; if (trimmed.length > 60) return `${trimmed.slice(0, 24)}...`; if (trimmed.length <= 4) return '...'; return `${trimmed.slice(0, 2)}...${trimmed.slice(-2)}`; } $ grep -rn "maskValue" app/src app/src/form-schema.ts:296:export function maskValue(value: string): string { One hit: the definition. Nothing calls it.
What a recorded step stores instead is this, straight from the type:
export type BindingSource = /** Replay exactly what was typed. Dates that have passed are handled at replay. */ | { kind: 'literal'; value: string } /** Leave the field untouched. */ | { kind: 'skip' }; "Replay exactly what was typed" and "recorded values are masked" cannot both be true, and the type wins. It is not an oversight either, it is the feature: once the shared profile and the shared message were deleted, the only place a reply's content could live was the recording. A masked value cannot be replayed into a form.
So recipes.json does contain personal details, whatever the form asked for, and it is encrypted at rest through the same safeStorage envelope as everything else in that folder. That is a perfectly defensible design. It is just not the one the page describes, and three places in the repo describe the old one.
I spotted the orphaned function first in a census of exported symbols nothing imports, where it was one of 14 and I called it a leftover of a refactor. It is a leftover of a refactor. What that census could not see is that a published page depends on it.
Bullet 4: a log in two places, neither of them the one named
"Activity log lines" sounds like the scrolling log in the app window. That log is not on disk:
// app/src/server.ts const LOG_BUFFER_SIZE = 200; const logBuffer: string[] = []; 200 lines in memory in the main process, mirrored by a 200-line cap in the window's reducer, and both die with the process. Nothing writes them anywhere.
There is a log file, though, and it is not in the list:
// app/src/main.ts const LOG_PATH = path.join(app.getPath('userData'), 'logs.txt'); function logFile(msg: string): void { const line = `[${new Date().toISOString()}] ${msg}\n`; process.stdout.write(line); try { fs.appendFileSync(LOG_PATH, line); } catch { /* ignore */ } } 18 call sites. Two are the uncaughtException and unhandledRejection handlers, several are startup diagnostics, and these two are the reason this bullet matters:
logFile(`[login] Exported ${cookies.length} cookies for ${hostname}`); logFile(`[login] Reset session for ${hostname}`); logs.txt is append-only with no cap and no rotation, and it accumulates a timestamped record of which rental sites you signed into and when. A packaged Electron app has no terminal, so a file like this is the only way to debug a user's startup failure, and I would not remove it. It belongs in the list.
The other missing file
finds.json is a 200-entry ring buffer of what the monitor has found:
export interface Find { id: string; siteName: string; siteUrl: string; title: string; url: string; foundAt: number; } Listing titles, URLs and timestamps, written after each alert goes out so the window can show you what turned up overnight. Plain JSON, not encrypted, which is fine because every one of those fields was a public listing. Also not in the list. The nearest bullet is "snapshots of listing results", which is a different file for a different purpose, and the distinction matters precisely because the snapshot is deleted when you re-point a search while the finds are kept.
entitlement.json is the fifth file nobody mentions, and the only one I am comfortable leaving out: it is a cached yes or no about whether this licence bought the auto-reply upgrade, with no personal data in it.
The corrections
Five edits to the page, no code changes needed for four of them:
- Replace the reply-profile bullet with the recordings, which is where that data lives now.
- Drop the separate bullet for the message.
- Rewrite the masking sentence to say what is true: a recording stores the values you demonstrated, because that is what gets replayed, and the file is encrypted at rest with your OS credential store.
- Extend the encryption sentence to name
recipes.json, not justprofile.json. - Add
logs.txtandfinds.json, with what each one holds.
And one in the app: delete the two comments that promise masking, and either wire maskValue into the preview field the UI was going to have, or delete it.
The general lesson I would take from this is that a privacy policy decays like a comment, not like code. Every one of these drifted because a feature got simpler: a profile became a recording, a shared message became part of a recording, a masked preview became an unbuilt UI. Deleting code is the safest thing you can do to a program and one of the riskiest things you can do to a document about it, because nothing fails.
If you want to read the page this audit was against, it is at notifio.app/privacy, and the help page explains what the app is doing with each of those files while it runs.

