0 Euro Running Cost: A Local Mail Assistant Built on Apple Foundation Models
An email assistant for macOS Mail.app that runs on-device: PII redaction before every LLM call, and a silent macOS 26.5 bug that quietly killed the fast SQLite query.

TL;DR
I am building an email assistant for macOS that classifies incoming mail and drafts replies without burning cloud tokens to do it. The trick is not magic: Apple Foundation Models run on-device, Ollama steps in locally, and only as a last resort does anything go through a cloud fallback. The result is 0 Euro in running costs and a privacy approach that strips personal data from every prompt before the LLM call. This article shows two things honestly: what the local-first build actually looks like, and a silent bug that killed the fast SQLite query after a macOS update without throwing a single error. The tool is in preparation, not a sales promise.
Why I Am Building This
For many freelancers and small teams, email is the invisible time sink. Classifying, prioritizing, drafting: these are exactly the tasks a language model handles well. The reflex of most vendors is to send every single email to a cloud API. That is quick to build, but it costs two things: ongoing token fees, and your mailbox contents leaving the device.
For you as a decision-maker, the core takeaway is this: sensitive mailbox contents do not leave the device, and no running costs accrue. You can hand the technical part of this article off to your team.
Both bothered me. Anyone processing business mail routinely sends invoice amounts, IBANs, partner names, and the occasional social security number through someone else's inference pipeline. Even with zero-retention promises, that is a trust decision I did not want to make for my own data. And token cost scales with mailbox volume, meaning it climbs exactly where the assistant would be most useful.
So I flipped the question: how much of this work can run fully locally, so that normal operation costs 0 Euro and no mail content leaves the device? The answer is pleasantly large. I worked through this same principle at the level of entire stacks in EU Data Sovereignty in AI Tech Stacks; here is the concrete version inside a single tool.
The Architecture in One Sentence
A background daemon watches the local Mail.app, a small SwiftUI program in the menu bar shows the suggestions, and the actual computation happens on the device. Concretely:
- Daemon. Bun and TypeScript, bound to
127.0.0.1:7879. It reads mail, classifies it, proposes drafts, and learns from corrections. - Menu bar app. Swift 6 and SwiftUI as a
MenuBarExtra. It is the only surface the user sees. - LLM layer. Apple Foundation Models on-device by default, Ollama with a local Gemma model as a second local option, and a cloud fallback only for emergencies.
The mail integration deliberately runs through Apple Mail.app as the single surface. Outlook, Gmail, and iCloud accounts mirror into Mail.app anyway once you add them there as a second account. That way I do not maintain a dozen provider APIs; I read the local mail database instead.
Local-first does not mean "no internet." It means the default paths compute on the device, and anything that does go out is the conscious exception.
Point 1: 0 Euro Operation Through a Fallback Chain
The economic promise of "0 Euro operation" stands or falls on a single design decision: which backend computes first, and what happens when it fails?
The order is a configurable fallback chain. By default, Apple Foundation Models compute on-device. If the on-device bridge does not respond, times out, or returns an HTTP error, the call moves to the next stage. Simplified, the dispatch logic looks like this:
async function dispatchWithFallback(prompt: string, settings: Settings) {
const chain = ['foundation', ...settings.fallbackChain]; // e.g. ['foundation','openrouter','ollama']
let lastError: unknown;
for (const backend of chain) {
try {
return await runBackend(backend, prompt);
} catch (err) {
if (!isRecoverable(err)) throw err; // don't swallow real errors
lastError = err; // bridge-not-running | timeout | http -> next stage
}
}
throw lastError;
}
The key point: the first stage, Apple Foundation Models, costs nothing. It ships with macOS and runs locally on the device. The second local option, Ollama with a Gemma model, also costs nothing beyond electricity. The cloud stage exists only as a safety net for devices without on-device AI, or for the rare case where a longer draft overwhelms the local models.
For normal operation on a current Mac, that means every incoming mail is classified locally, every draft written locally, and the monthly bill stays at zero. This is not magic. It is a consequence of Apple having moved inference into the operating system. If you want to follow the build of such a local assistant step by step, the method is linked later in this article.
Point 2: PII Redaction Before Every LLM Call
The second part worth telling is a decision I made non-negotiable from the start: before any text reaches any model, it passes through a redaction stage that detects and replaces personal data.
Concretely, that covers IBANs, credit card numbers, Austrian social security numbers, and phone numbers. The pattern is simple: a redact() function runs over every prompt, regardless of backend.
function redact(text: string): string {
return text
.replace(IBAN_RE, '[IBAN]')
.replace(CREDIT_CARD_RE, '[CC]')
.replace(AT_SVNR_RE, '[SSN]')
.replace(PHONE_RE, '[PHONE]');
}
// Before every dispatch, not just on the cloud path:
const safePrompt = redact(systemPrompt) + redact(userPrompt);
The obvious objection: if the on-device models never leave the device, why redact their prompts too? For two reasons. First, as defense in depth: if the fallback chain does eventually drop to the cloud path, the text is already clean, rather than relying on a branch that could break someday. Second, for a uniform audit trail: I want to be able to say that no prompt passes through the system without redaction, without having to check each time which backend was active.
I name one limitation honestly, because it matters: raw image bytes cannot be redacted the way text can. A screenshot containing an IBAN is still an IBAN. The consequence: attachments with pure image content are forced hard onto the on-device path and never go to a cloud backend. Text from PDFs or audio transcripts, by contrast, rides along on the redacted channel.
Point 3: The Silent Bug That Taught Me the Most
Now to the part that sounds like debugging, because it was. And the most unpleasant kind of bug: one that throws no error.
The Fast Path
For context: there are two ways to read mail from Mail.app. The convenient way is AppleScript, which works reliably but needs a script invocation per query and is correspondingly slow. The fast way reads the local SQLite envelope index that Mail.app maintains directly. In my measurements, the SQLite path is two to three orders of magnitude faster than AppleScript. That speedup is not a luxury; it is the difference between "inbox reviewed in a second" and "the fan spins up."
So the system never breaks hard, there is a deliberate fallback stage: if the fast SQLite query fails to deliver for any reason, the code falls back to the slow AppleScript floor. Everything stays functionally correct, just slower. That exact safety mechanism is what hid the bug.
The Symptom
After a macOS update to 26.5, I noticed that mailbox listing had become sluggish again. No error message, no crash, no red logs. The app ran, read mail, proposed drafts. Just noticeably slower. If I had not happened to pay attention to speed, I might not have noticed for weeks.
The reason for the invisibility is exactly the fallback stage above: the fast SQLite query failed, the code fell cleanly to the slow AppleScript floor, and from the outside everything looked operational. A silently dead fast path is more insidious than a crash, because nothing nudges you toward the problem.
The Root Cause: A Schema Drift
The actual cause sat in the Mail.app database schema. With macOS 26.5, Apple had changed the internal structure of the envelope index without it appearing in any documentation. Three things had shifted:
- Columns disappeared. Fields my query referenced by name were suddenly renamed or no longer in the expected table.
- A relationship direction reversed. A foreign key I used to join two tables pointed the other way in the new schema version.
- The join ran empty. Together, the query returned zero rows instead of an error, and zero rows is the same signal to the fallback logic as "better read via AppleScript."
That is the lesson I take away: a query against a foreign, undocumented database is a dependency that can break out from under you at any time, and quietly. The operating system update promised nothing and broke nothing, in the formal sense. It merely changed an internal structure my code held an assumption about.
The Fix
I repaired it with two measures. First, I adapted the query to the new schema: the corrected join direction, the renamed columns caught through more robust expressions, and missing values replaced defensively with COALESCE to empty strings instead of trusting they exist.
-- defensive against schema drift: missing column -> empty string instead of NULL break
SELECT COALESCE(mb.url, '') AS mailbox_url, ...
FROM messages m
JOIN summaries s ON s.message = m.ROWID -- corrected join direction
LEFT JOIN mailboxes mb ON mb.ROWID = m.mailbox
Second, and this is the more important part, I changed the verification. I no longer rely on "zero rows" automatically meaning "read via AppleScript." Instead, I check the fast path against the real index and confirm it actually returns rows before trusting it. I verified against a real mailbox with several hundred messages, not a synthetic test dataset, because exactly that real-data comparison is what made the schema drift visible in the first place.
This second measure is the real lesson. Fixing the query was craft. Verifying against real data is what catches the next silent bug of this kind earlier. I needed the same reflex in my failed local coding setup: a status that says "all green" is not proof the system is actually working.
What Generalizes From This Bug
Three things I take into every tool that works against foreign systems.
Silent fallbacks need an alarm. A fallback that works cleanly is dangerous precisely because it swallows a problem. If you have a fast path and a slow path, log when the slow one kicks in, otherwise nobody notices the fast one died.
Undocumented dependencies are ticking assumptions. The Mail.app SQLite index is not a published API. Reading it buys enormous speed and, at the same time, the risk that an update shifts the structure. You can make that trade, but you have to make it deliberately and plan for the break.
Verification beats status. "100 percent loaded," "query completed," "no errors in the log" are status signals, not proof of function. The only reliable evidence was running the query against real data and counting the rows.
Where the Tool Stands
Honestly: this is a tool in preparation, not a finished product with a buy button. It runs in production for me, it is built as a signed and notarized program with an automatic update path, and it does exactly what I need it to. But it is a solo build that is still maturing, and I frame it that way deliberately, rather than making a promise I cannot yet keep for other people's mailboxes.
What I can say with confidence from this build: a local, free mail assistant is technically feasible today, because the on-device inference in macOS has become good enough for classification and drafting. AI is a tool here, powerful but not magic, and the hard part was not the model but the invisible database underneath it.
If You Want to Run This Yourself
If you want to run an assistant yourself that computes locally, does not hand mailbox contents out uncontrolled, and stays clean under GDPR: I have worked through exactly this safe operation of a self-hosted assistant, step by step, in the course at agenticbuilders.at/24-7-assistent, including the question of what you should never expose publicly. And if you have a concrete project where data sovereignty and local operation need to come together, I deliberately work with only 1 to 2 clients at a time and take the time for an honest assessment, via contact.