A Custom App for Archiving Invoices and Documents — and the Moment Right Before Launch When Real Data Almost Leaked
An app built so an important piece of paper (an invoice, a contract, a birth certificate) can be found again in a few seconds — not because it's fun, but because "where did I put that insurance policy" is a question every household asks itself.
Why a custom app, not an off-the-shelf solution
The goal: a phone photo, an email attachment, or an uploaded PDF gets processed automatically — pulling out the main identifier (the company on an invoice, a name on a birth certificate), the document type, the date, the amount, a short summary — and the file gets stored so it can later be found with a simple search ("acme insurance" → everything from that insurer, a child's name → their birth certificate). Not just invoices and contracts — anything important that might need to be looked up quickly at some point in the future.
Existing off-the-shelf solutions (e.g. paperless-ngx) do something similar via OCR and full-text search. The difference here was in the chosen approach to metadata extraction: instead of just OCR text, the app sends the document off for real AI extraction (recognizing the document type, pulling out key fields, producing a summary) — with a multi-layer fallback across AI providers, so that one outage doesn't stop the whole archive.
Architecture borrowed, not reinvented
Rather than designing an architecture from scratch, the app adopted a proven convention from another, already-existing project in the same homelab: a FastAPI backend, a React+Vite frontend, session-cookie authentication with CSRF protection instead of JWT. Deviations from that convention were deliberate and justified: SQLite with the FTS5 extension instead of flat JSON files (needed for real full-text search across the archive), and a single process serving the frontend too, with no separate reverse-proxy container — because of a requirement that the app run over both Docker and systemd on any Linux machine, not just this particular one.
A silent bug that was losing real mail
The app was supposed to watch a mailbox and automatically archive email attachments. A test with a real email (a forwarded payslip) never showed up in the app at all.
Investigation turned up two independent things at once. First, the original mail-watching mechanism only looked for unread messages (SEARCH UNSEEN) — that particular message already had the "read" flag set (likely by some server-side background scan; the exact cause couldn't be pinned down with certainty), so the app simply never saw it. A fragile mechanism regardless of the exact cause — the fix was to track its own position (a UID watermark, "processed up through sequence number X"), independent of whatever else on the server does with the "read" flag.
Second, and maybe more surprising: the app had logging configured, but nowhere in the code was the logging system's own configuration actually initialized — which in practice meant every informational log line in the entire app was being silently dropped by the default logging level (warnings and above only). Months of the app running without a single visible info message in the logs, despite the app clearly using logging throughout. One line of initialization fixed it for the whole app at once.
(And as a small footnote: the actual test email turned out not to contain an attachment at all — the mail client had silently dropped it on forward. So the app was behaving exactly correctly in this one specific case; it just happened to overlap with two real bugs discovered along the way.)
Duplicates that never got cleaned up
While testing a watched folder (copying an already-archived file back into the input folder), the app correctly recognized the duplicate — but its behavior depended on where the document had come from. For uploads via the app or via mail, a duplicate was harmless, because the calling code cleaned up the source file itself either way. For the watched folder, though, nobody cleaned anything up — the app would just quietly stop without touching the source file, which would then sit in the input folder forever, getting re-evaluated as a duplicate over and over on every subsequent scan.
Fix: duplicate files now get moved into a separate folder instead of staying in place — the input folder stays clean, and nothing gets sent off for AI extraction repeatedly for no reason.
Tests that didn't exist before
Part of getting ready to publish was also the app's first real suite of automated tests — it had none before. Eighteen tests running offline in under two seconds (no real calls to AI providers, replaced with a fake provider for testing), covering login consent, correctly telling a temporary provider outage apart from a permanent extraction failure, full-text search, GDPR-compliant deletion of a file from disk, CSRF protection, and document review state transitions. It also came with an honest document describing what these tests don't cover — the frontend, real AI providers, background processes — instead of pretending everything was covered.
The moment right before launch worth remembering
While preparing the app for a public GitHub repo, a set of demo documents was created (fictional companies, fictional names) along with screenshots for the README — the app had, until then, only ever run against real family documents.
A pre-publication check revealed that one of the screenshots (the Settings page) contained more than it should have: the real IP address of the home mail server, the real mailbox address the app receives invoices at, and a real Telegram chat ID used for notifications. None of it was a password or an access token, but all of it was real, specific detail about private infrastructure that had no business being in a public photo in a README.
Caught and fixed before publishing — the settings were temporarily swapped for demo values just long enough to take the screenshot, then switched straight back to the real ones (with a check that notifications still worked afterward). As a smaller, lower-severity find, a later pass through the git history turned up one more real email address — in an old example in the install docs and in one commit message. Fixed by rewriting history (a repository history-rewrite tool, run twice — once for file content, once separately for commit messages, since a single pass doesn't catch both), with a full backup of the original repo kept only locally before touching anything.
The lesson from this moment is simple, but easy to forget: getting ready to go public isn't just about the code and the documentation — it's about every image you package up with it, too. A screenshot of an app running on real data is exactly the spot where a sensitive detail slips out most easily, because it doesn't get checked with the same care as the source code.
The last step: wipe the dev data and start for real
After the repo went public, the last step was to clear out months of accumulated test and development documents (a mix of real and test files) and start using the app for real. The scope of the cleanup was deliberately narrow: only the documents themselves were wiped, not the mail/Telegram settings (already working, no reason to set them up again) or the user account. A backup was taken as the first step before touching anything, not as an afterthought.
What to take away
- A borrowed architectural convention saves time — deviations from it deserve their own justification, not silent improvising wherever it happens to be convenient.
- A filter on "unread" is a fragile way to track new data, if anything else in the system can change that same flag before the app gets a chance to see it. A position marker the app owns itself is more resilient.
- Missing logging initialization is one of the quietest bugs an app can have — it doesn't throw an error, it just silently makes the exact information that would help most during debugging disappear.
- Duplicate handling doesn't behave the same way across every input path, unless someone explicitly checks it — what's safe on one path can pile up indefinitely on another.
- A screenshot is part of the surface that needs checking before launch just as thoroughly as the code. It's exactly the spot where real data leaks out most easily, and most quietly.
The app runs for real today, on real family documents — and thanks to that last round of checking, without a single extra detail leaking out on the way to going public.
Sindri (a script catalog) went through the same kind of pre-publish discipline — and the same "verify it for real" principle runs through the whole build of MidgardOps too.