When I wrote about Tower in June, I ended the post with a short wish list: a proper log viewer so I could stop falling back to journalctl over SSH, and write access on the Firewall page so I could add a ufw rule without leaving the dashboard. Both of those landed within a fortnight and neither is worth a blog post.
What happened after that is worth a post. Over the last six weeks Tower went from something that watched my infrastructure to something that does work on my behalf while I am not looking. It now reads my email, files my expenses, tracks my solar panels, backs up the whole server to two places, and screens job alerts. That is 222 commits and eleven new pages since the last post, and most of it was not on any plan I had written down.

Gmail Turned Out to Be the Most Useful Sensor in the House
This started as a small annoyance. I have been running a personal finance app for a while, and the tedious part was never the analysis — it was typing in transactions. Every PickMe trip, every Keells bill, every Daraz order sends me an email with the amount in it. That data was already sitting in my inbox. I was just re-keying it by hand.
So I built a Gmail connector into Tower and pointed a worker at a label called Bills. The worker reads unprocessed messages, matches each one against a profile, extracts the amount and the date, and posts it to FinanceTracker's external API as a transaction. Then it trashes the email.
It worked well enough on the first three senders that I kept adding profiles. Then I did something I had not planned: I removed the date bound and let it sweep the entire label. Gmail had been quietly keeping every one of these receipts since 2014.
- 1,516 bills imported, the oldest dated 5 November 2014
- 59 distinct senders matched — PickMe alone accounts for 675 of them across trips, deliveries and cancellations
- Keells 234, Daraz 184, AliExpress 59, Dialog 71, Google Play 44
Twelve years of spending history that I had never once looked at, reconstructed in an afternoon from a folder I had been ignoring. That is the part that genuinely surprised me.
The same shape then got copied twice more. A Statements label sweeps bank and credit card statements into FinanceTracker as account balances — 610 statements now, going back to February 2018, across 23 profiles covering Sampath, Commercial, BOC, NTB, CAL, FriMi and DFCC. A Jobs label sweeps job alert digests into a review board on /jobs — 374 roles from 292 companies.
The jobs one has a wrinkle the others do not. A bill email holds one amount. A job digest holds fifteen roles in one email. So a job profile's block pattern gets applied to the body repeatedly, and every match becomes a row.
The Profiles Do Not Live in the Code
The first version had the sender patterns compiled into C#. That lasted about four days. Adding a profile meant a rebuild and a redeploy, which meant I would batch them up, which meant emails piled unimported in the label.
So the profiles moved into XML files that sit next to the binary in /home/atom/Tower. The version-controlled master is embedded in the assembly; on startup Tower writes it out as a reference copy and seeds the live file only if the live file is absent. Editing the live file and pressing a reload button on the page is the whole loop. No rebuild, no redeploy, no restart.

Here is what a real profile looks like. These three are the DFCC card alert profiles — the bank sends an email the instant a card is swiped, which means Tower knows about a charge before I have left the shop:
<Profile name="DFCC Visa Infinite" from="DFCC_Card_Alerts@dfccbank.com"
category="Uncategorized" currency="LKR" account="4199…" noDedup="true">
<Subject>E-alert from DFCC Bank</Subject>
<Body>CARD\*\*7032</Body>
<Description>CARD:\s*(.+?)\s+CARD\*\*7032</Description>
<Amount>CARD\*\*7032 DEBITED LKR\s*([\d,]+\.\d{2})</Amount>
</Profile>
The <Body> line is doing the real work there. The sender and the subject are byte-identical for every card on the account, so the last four digits in the body are the only thing that tells one card from another. One profile per card. An alert for a card with no profile matches nothing and stays in the label, which is deliberate — I would much rather import nothing than book a charge to the wrong account.
The category is Uncategorized on purpose too. The bank tells me the merchant, not what I bought, so leaving it as a placeholder is what lets FinanceTracker's duplicate finder pair the alert with the shop's own emailed receipt when that arrives a minute later.
Four Days I Would Like Back
None of this went smoothly, and the failures were all in the same category: I trusted what I thought the email contained rather than what my own code actually produced.
Line endings. The first Google job profile matched zero jobs and I could not see why. The pattern needed a blank line between entries, so it had \n\n in it. Email bodies are CRLF. It could never match. Worse, I spent an hour measuring the wrong thing: I dumped a body out of SQLite to a file to inspect it, and sqlite3 silently strips the carriage returns on the way out. The file I was staring at genuinely had no CRs in it. The data in the database did.
Writing patterns against the wrong text. I wrote the first profiles against what the Gmail API returns as the plain-text body. Tower does not use that directly — it goes through its own reader with its own fallbacks. Those are not the same string. Now I capture a real body out of the database and test against that.
The 41 emails that would not budge. Thirty-nine Workopolis digests and two from Jaguar Land Rover sat in the label being matched and yielding nothing. They had no plain-text part at all, so extraction fell through to my HTML tag-stripper — which was leaving the CSS text from inside <style> blocks in the output and converting HTML indentation into leading spaces. Every pattern expecting a title at the start of a line broke. The fix was six lines in the shared reader, and because it is shared, the bill and statement importers got the same fix for free.
That last one taught me the rule that now governs all three importers: when a profile matches an email but extracts nothing, store the body. That is precisely the case where you need the raw text, and it was originally the one case I was throwing away.
The related lesson is about silence. A Dialog bill went months unimported because the 2026 PDF template renamed one field, and my parser skipped it quietly. Now a matched email whose amount will not parse writes an error that shows on the page. I would rather see a red line than a clean page that is clean because nothing happened.
The Solar Panels Report Themselves
The solar array has been on the roof for a while and I had only ever tracked the financial side of it. The inverter's cloud service emails a daily performance report, and that mail was going into a label and being ignored. Same trick as the bills — parse it, store it, chart it.

372 daily reports going back to July 2025, and the running totals are real numbers I can now see without logging into anyone's portal: 11,390 kWh generated, LKR 314,315 earned, 8.08 tonnes of CO₂.
The part I am actually pleased with is the sunlight line. A bare yield chart is close to useless in Sri Lanka, because a bad number in the middle of the monsoon is not a fault — it is just weather. So Tower pulls daily shortwave radiation for Colombo from Open-Meteo's archive, which is free and needs no key, and draws it over the yield bars. Now the question the chart answers is not "did I generate less today" but "did I generate less than the sunlight allowed". A performance ratio under 0.80 across a rolling week raises a maintenance banner, which in practice means dirty panels. Inverter alarm emails get parsed too and show up as red markers on the same chart.
The honest part: the five-minute realtime poller I wrote for the inverter's API has never run once. It needs a token and the inverter serial number entered on the settings page, I never got round to it, and the snapshot table is still empty. Every number above comes from parsing emails. The fancier half of that feature has been sitting there dead for a month and I have not missed it, which tells me something about which half was worth building.
Backups, Properly This Time
My storage pool is 3.6 TB of linear LVM across four disks with no redundancy at all. I have written before about how that pool came together. It is fine for media I can re-download. It is not fine for the roughly 19 GB of photographs and project files that exist nowhere else, and losing one disk loses the lot.
The old Tower had a nightly job that pushed SQLite snapshots to Dropbox. It worked, but it only ever handled databases, and Tower was holding Dropbox OAuth credentials to do it. I replaced the whole thing with rclone.

Tower now discovers what it can back up, I pair each source with a target on a page, and a worker ticks every sixty seconds deciding what is due. Sources are folders, live SQLite databases hot-copied through the Online Backup API so they are consistent while in use, and a category I had to add called extra sources. Targets are either an rclone remote or a physical drive pinned by its filesystem UUID — never /dev/sdX, because those letters shuffle between boots and one reboot would have it happily syncing the pool onto itself.
The safety net that matters is one flag:
--backup-dir /path/.tower-trash/2026-08-05 --max-delete 100
Anything a sync would overwrite or remove goes to a dated trash folder instead of vanishing. And if a source ever disappears — an unmounted drive reading as an empty directory — a plain mirror would cheerfully empty the destination and record a success. Above a hundred deletions in one run, rclone aborts and the pair is marked failed, which is what a catastrophic source loss ought to look like.
Two Things I Got Wrong
The first run wrote at the root of my Dropbox. Not into a folder — at the root, scattering backup directories in among my own personal folders. Everything now goes under a configurable base folder grouped by project, and moving the existing data across was a server-side rclone move, so at least it was instant rather than a re-upload.
The second was a Blazor mistake that cost me two rounds of confused debugging. I had registered the sync configuration object in dependency injection as a scoped service. In Blazor Server a scope lives as long as the circuit — so the config froze the moment I opened the page, and the "Reload config" button did nothing while appearing to work perfectly. Twice. Everything now takes a provider and reads the current value, and I deleted the registration so I cannot make that mistake a third time.
The File I Nearly Lost Without Knowing
While auditing what actually needed backing up, I found something I would not have thought of. FinanceTracker encrypts the passwords for thirteen bank statement PDFs. ASP.NET's data protection puts the key ring in ~/.aspnet/DataProtection-Keys by default, and I had never told it otherwise. Restoring the database without that directory would have left all thirteen accounts' statements permanently unopenable. It is 16 KB. It was in no backup.
The same audit turned up tinytuya_service/devices.json, which holds the local keys for every smart device in the house and is deliberately gitignored, so it existed in exactly one place on one disk. Those files are why extra sources exist as a concept — the folder discovery only returns directories, and the most irreplaceable things I own turned out to be loose files.
242 runs so far: 238 succeeded, 2 failed, 2 were interrupted by a restart. There is one piece of debt I am carrying deliberately — nothing prunes the trash folder yet, so every database pair drops a full copy in there on every run, roughly 5 MB a day forever. I left it that way on purpose. Writing a pruner before the max-delete guard existed would have turned "source wiped" into real data loss once the trash aged out. It can wait until the trash is measurably a problem.
The Smaller Things
- Automations — named routines that fire a list of actions at smart plugs, lights, the AC and the Raspberry Pis. Triggered from the page or by sending
run bedtimeto the Telegram bot. Bedtime turns the bulb on, sets the baby room AC to 27°C and shuts down both Pis. - Tuya device categories — device controls are now driven by declared capabilities rather than a type enum, so adding a new class of device is a few lines in one file instead of a new branch in every view.
- A password vault on
/secrets, holding 17 project credentials. Values are AES-256-GCM encrypted at rest behind a master password, and the key is never stored — it lives in memory for the duration of a session. Which does mean I can no longer read my own secrets out of the database withsqlite3. That is the point, and it is still mildly irritating. - A responsive pass over all 21 pages. I did this the wrong way first — reading the markup and reasoning about it. Then I rendered every page at three viewport widths and looked, and found a dozen problems the markup had not suggested. Including a Pi-hole panel that had been confidently reporting 141% of queries blocked for weeks.
Where It Stands
Tower is 21 pages, roughly 65,000 lines of C# and Razor, and 446 tests. It has been running on Atom without incident throughout, and the deploy is still the same three steps it has always been: stop the service, publish, start it again.
The shift I did not anticipate is that it stopped being a dashboard. I open it far less than I did in June, which is the correct outcome — the bills file themselves, the backups run at two in the morning, the solar reports parse without being asked. What I check now is whether anything went wrong, not what the numbers are.
What is next is unglamorous and specific. The trash pruner, once it starts costing real space. Card alerts still have no statement profile on the DFCC side after Standard Chartered wound up its Sri Lankan retail business and my two cards moved across. And I would like the job board to stop showing me the same reposted role three times a week, which is a deduplication problem I have been avoiding because it is genuinely hard.
The source is still private — there is too much of my own infrastructure wired into it to open as it stands.