ai newsSo., 25. Okt.
EilmeldungStudie von Anthropic: KI-unterstuetztes Lernen brachte Devs 17 Punkte weniger ein, Junioren sollten zuerst solo coden

I built an AI email assistant in .NET with SerpApi and a local LLM. Here's what broke.

I had 53 unread emails and no idea which ones actually needed me. Newsletters, payment notices, receipts, and somewhere in between, a few real people waiting for an answer. I taught English for 16 years before I switched to software in 2023. If teaching taught me one thing, it's that you learn a to

dev.toSonntag 12:17 · 25.10.6 Min. Lesezeit
Bild: dev.to

I had 53 unread emails and no idea which ones actually needed me. Newsletters, payment notices, receipts, and somewhere in between, a few real people waiting for an answer.

I taught English for 16 years before I switched to software in 2023. If teaching taught me one thing, it's that you learn a tool fastest when you point it at a real problem. My inbox was a real problem.

So I built a small.NET console app that reads my inbox, skips the noise, drafts replies for the emails that matter, and tells me who the sender's company actually is. I set two rules on day one:

The code is on GitHub: github.com/barbarosalagoz/MailAssistant

Gmail (IMAP, read-only) └─> filter newsletters and notifications └─> local LLM (Ollama): category + summary + draft reply └─> SerpApi: search the sender's company └─> local LLM: two-sentence company summary Enter fullscreen mode Exit fullscreen mode The stack:

dotnet new console -n MailAssistant cd MailAssistant dotnet add package MailKit dotnet add package serpapi dotnet add package Microsoft.Extensions.Hosting dotnet add package Microsoft.Extensions.Http Enter fullscreen mode Exit fullscreen mode Secrets go into.NET User Secrets, so they never end up in the repo. I first set them as environment variables in PowerShell, then realized PowerShell keeps a history file with every command I'd typed, API key included. I cleaned it up and switched to this:

dotnet user-secrets init dotnet user-secrets set "MAIL_USER" "you@example.com" dotnet user-secrets set "MAIL_APP_PASSWORD" "your-gmail-app-password" dotnet user-secrets set "SERPAPI_KEY" "your-serpapi-key" Enter fullscreen mode Exit fullscreen mode Step 2: Register SerpApi with dependency injection The serpapi package ships an AddSerpApi extension that registers SerpApiClient as a typed client through IHttpClientFactory. Connection pooling and lifetimes are handled for you, the same way you'd expect in ASP.NET Core:

var builder = Host. CreateApplicationBuilder ( args ); builder. Configuration. AddUserSecrets < Program >( optional: true ); builder. Services. AddSerpApi ( options => { options. ApiKey = builder. Configuration [ "SERPAPI_KEY" ]?? throw new InvalidOperationException ( "SERPAPI_KEY is not set." ); options. Timeout = TimeSpan. FromSeconds ( 30 ); }); Enter fullscreen mode Exit fullscreen mode Before processing anything, I call the Account API to check how many searches I have left. It doesn't use a search credit, which matters when you're on the free plan:

var serp = host. Services. GetRequiredService < SerpApiClient >(); using ( var account = await serp. AccountAsync ()) { Console. WriteLine ( $"SerpApi: { account [ "total_searches_left" ]} searches left." ); } Enter fullscreen mode Exit fullscreen mode Step 3: Filter before you prompt This turned out to be the most useful part of the whole project. Most of a real inbox doesn't need AI at all. Bulk mail usually carries a List-Unsubscribe header, and automated senders follow predictable patterns:

static bool IsBulkOrAutomated ( MimeMessage m ) { if ( m. Headers. Contains ( "List-Unsubscribe" )) return true; var address = m.

From. Mailboxes. FirstOrDefault ()?.

Address?. ToLowerInvariant ()?? ""; var parts = address.

Split ( '@' ); var local = parts [ 0 ]; var host = parts. Length > 1? parts [ 1 ]: ""; string [] localMarkers = { "noreply", "no-reply", "notifications", "failed-payments", "billing" }; string [] hostLabels = { "mail", "trx", "info", "news", "mailer", "bounce" }; return localMarkers. Any ( local.

Contains ) || host. Split ( '.' ). Any ( label => hostLabels.

Contains ( label )); } Enter fullscreen mode Exit fullscreen mode My first version had no filter. The model happily wrote polite replies to no-reply addresses, and I burned SerpApi searches looking up the companies behind newsletters. Once the filter was in, most of the inbox never reached the model.

That's when it clicked for me: the AI was the smaller part of the job.

For each email that's left, I take the sender's domain and search it with the google_light engine. The response gives you OrganicResults as a JsonElement, so you can read just the fields you need:

var parameters = new Dictionary < string, string > { [ "engine" ] = "google_light", [ "q" ] = domain, [ "hl" ] = "en" }; using var results = await serp. SearchAsync ( parameters, ct ); if ( results. OrganicResults is { } organic ) { foreach ( var r in organic. EnumerateArray (). Take ( 5 )) { var title = r. TryGetProperty ( "title", out var t )? t. GetString (): ""; var snippet = r. TryGetProperty ( "snippet", out var s )? s. GetString (): ""; sb. AppendLine ( $"- { title }: { snippet } " ); } } Enter fullscreen mode Exit fullscreen mode The package also gives you typed exceptions, so each failure gets a message a person can act on instead of a stack trace:

catch ( SerpApiKeyException ) { summary = "(Invalid SerpApi key. Check SERPAPI_KEY.)"; } catch ( SerpApiHttpException ex ) when ( ex. StatusCode == 429 ) { summary = "(SerpApi quota or rate limit reached.)"; } catch ( SerpApiTimeoutException ) { summary = "(SerpApi request timed out.)"; } Enter fullscreen mode Exit fullscreen mode What broke The setup, before any AI ran. My first dotnet new failed because the terminal had opened in C:\Windows\System32. Then I pointed the app at https://localhost:11434 and got a cryptic SSL "frame size" error. Ollama speaks plain http locally. Then Gmail wanted an app password, and my Google Workspace account only allowed passkeys, so I tested with a personal Gmail account instead. None of this is glamorous, but it's the part tutorials usually skip.

The model invented a phishing scam. A Disney+ payment email came from trx.mail2.disneyplus.com. I searched that exact subdomain, got almost nothing back, and the model decided Disney+ was a phishing operation. Two fixes: search the base domain ( disneyplus.com ), and tell the model plainly that if the search results don't say it, it shouldn't say it either.

Halfway through a summary, it switched to Chinese. One company summary started in Turkish and ended in Chinese. An explicit "Turkish only, no other alphabet" rule fixed it. Small local models need very explicit rules.

The model copied a secret link into a draft. A failed-payment email contained a billing link with a secret token, and the model helpfully put it in the reply draft. Telling the model "no links" isn't enough on its own, so now the code strips every URL from drafts with a regex. The prompt asks; the code enforces.

The last one scared me the most. The phishing claim was wrong, but it was obviously wrong. The leaked link looked like a perfectly helpful reply, and that's exactly what made it dangerous. If the app had been allowed to send emails, it would have gone out.

Email is untrusted input, so I mapped the risks to the OWASP Top 10 for LLM Applications:

If you try the serpapi.NET package, I'd love to hear what you build with it. The full code is on GitHub.

I wrote this project and this post with help from an AI assistant. It suggested; I ran, broke, fixed and decided.

Quelle: dev.to (dev.to). Original öffnen