跳到正文
DEV Community· Barbaros Emre Alagöz·· 3 小時前AI 評分59

作者以 .NET、SerpApi 和本機大語言模型構建 AI 電郵助手,記錄實作問題

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

AI 導讀

作者以 .NET 建立 AI 電郵助手,透過 Gmail IMAP(唯讀)讀取郵件,過濾電子報及通知,再用本機 Ollama 上的 qwen2.5:7b 為相關郵件分類、摘要和草擬回覆。

正文

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:

  1. It never sends anything. It drafts; I decide.
  2. My email content stays on my machine. The language model runs locally with Ollama. Only the sender's domain goes out to the internet, to SerpApi.

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

How it works

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

The stack:

  • .NET Generic Host with dependency injection
  • MailKit for IMAP
  • the new official serpapi .NET package
  • Ollama running qwen2.5:7b, an open-weight model

Step 1: Set up the project

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

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"

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);
});

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.");
}

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));
}

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.

Step 4: Research the sender's company with SerpApi

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}");
    }
}

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.)";
}

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.

Security, briefly

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

  • Prompt injection: email content goes inside <email> tags and is treated as data, and links are stripped in code.
  • Sensitive data: the LLM runs locally; only the domain goes to SerpApi.
  • Excessive agency: the mailbox is read-only and nothing is ever sent automatically.
  • Misinformation: company summaries may only use what the search results actually say.

What's next

  • Save drafts straight into Gmail's Drafts folder
  • Replace the Gmail app password with OAuth2
  • Use SearchPagesAsync to pull recent news about the company, not just the first page of results
  • Run the same inbox through a few small open-weight models and see which one follows the rules best

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.

來源:DEV Community · dev.to