Self-hosted email intelligence powered by LLMs. Ingest your Thunderbird email archive, search it with natural language, and get AI-powered answers with full citations.
- Natural language email search -- Ask questions like "find all emails from Jane about the Q3 budget" or "summarize my conversations with the recruiter last month"
- Hybrid retrieval -- Combines vector similarity search (pgvector) with keyword matching (ILIKE) across sender, subject, and recipients, then merges and re-ranks results
- Context-budget aware -- Dynamically estimates how many emails fit in the active LLM's context window, caps per-message body length to distribute space evenly, and applies a relative score cutoff so noise doesn't crowd out relevant results
- Multi-account support -- Discovers all email accounts in your Thunderbird profile and exposes them as filterable checkboxes in the UI
- Thread-aware -- Reconstructs conversation threads and includes thread context for top results
- Conversation memory -- Each query is part of a persistent chat session; previous context carries forward until you start a new conversation
- Four LLM backends -- Anthropic Claude, OpenAI GPT, Google Gemini, or local models via Ollama
- Local embeddings -- All embeddings generated locally via Ollama (nomic-embed-text) -- your email content never leaves your machine unless you choose a cloud LLM
- Multi-chunk embeddings -- Long messages are split into chunks; each chunk is embedded individually and the best chunk match is used for retrieval
- Web UI -- Chat interface with streaming responses, inbox browser with sorting and pagination, ingestion controls, and settings dashboard
The Query page lets you chat with your email archive:
- Type natural language questions
- See status updates as the system searches, retrieves, and sends context to the LLM
- Source citations appear as cards below each response
- Filter by sender, date range, folder, or email account
- Conversation history persists across queries
The Inbox page provides a traditional email browser:
- Sortable columns (date, sender, subject) with ascending/descending toggle
- Full pagination with page jumps and fast-forward/reverse (10 pages at a time)
- Search and filter by sender, subject, folder, or account
- Docker and Docker Compose
- A Thunderbird mail profile directory
- At least one LLM API key (Anthropic, OpenAI, or Google Gemini) -- or use Ollama for a fully local setup
- Clone the repo:
git clone https://github.com/youruser/maillens.git
cd maillens- Copy the config templates:
cp config.example.yaml config.yaml
cp .env.example .env- Edit
.envwith your settings:
# Point to your Thunderbird profile's mail directory
MAILLENS_MAIL_DIR=/home/youruser/.thunderbird/xxxxxxxx.default-release
# Add at least one LLM API key
MAILLENS_GEMINI_API_KEY=AIza...
# and/or
MAILLENS_ANTHROPIC_API_KEY=sk-ant-...
# and/or
MAILLENS_OPENAI_API_KEY=sk-...-
Optionally edit
config.yamlto choose your LLM provider, adjust models, or tune retrieval settings. -
Start the stack:
docker compose up --buildOr with GPU acceleration (see GPU Acceleration):
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --buildFirst startup takes a few minutes while it pulls the embedding model. If active_provider is set to ollama, the LLM model is also pulled automatically.
-
Open http://localhost:8000 in your browser.
-
Go to the Ingestion tab and click Incremental Ingest to index your mail.
-
Once ingestion completes, go to the Query tab and start asking questions.
| OS | Typical path |
|---|---|
| Linux | ~/.thunderbird/<profile>/ |
| macOS | ~/Library/Thunderbird/Profiles/<profile>/ |
| Windows | C:\Users\<you>\AppData\Roaming\Thunderbird\Profiles\<profile> |
To find your profile name, open Thunderbird and go to Help > Troubleshooting Information > Profile Directory (or About > Troubleshooting Information). The path shown is your profile directory. On Linux/macOS you can also check ~/.thunderbird/profiles.ini.
Point MAILLENS_MAIL_DIR to the profile directory itself -- MailLens will discover all Mail/ and ImapMail/ subdirectories automatically and derive account names from the folder structure.
Windows + Docker Desktop (WSL backend): Docker Desktop runs inside WSL, so Windows paths must be converted to WSL mount paths. Replace
C:\Users\you\AppData\Roaming\...with/mnt/c/Users/you/AppData/Roaming/...in your.envfile. For example:# Windows path: C:\Users\aaron\AppData\Roaming\Thunderbird\Profiles\abc123.default-release # .env setting: MAILLENS_MAIL_DIR=/mnt/c/Users/aaron/AppData/Roaming/Thunderbird/Profiles/abc123.default-release
If you have an NVIDIA GPU, you can pass it through to Ollama for dramatically faster embedding generation (ingestion) and local LLM inference. Without a GPU, everything runs on CPU -- it works, but is slower.
Install the NVIDIA Container Toolkit on the host:
# Ubuntu / WSL
sudo apt install nvidia-container-toolkit
sudo systemctl restart dockerUse the GPU override compose file:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up --buildWithout a GPU, use the standard command:
docker compose up --buildYou can verify GPU detection in the Ollama logs -- look for lines showing GPU layers being offloaded rather than "offloaded 0/N layers to GPU".
The main configuration file. See config.example.yaml for all options with inline documentation.
| Section | Key settings |
|---|---|
mail |
Directory path, format (auto / mbox / maildir) |
embeddings |
Model (nomic-embed-text), chunk size, batch size |
retrieval |
Similarity threshold, thread context toggle |
llm |
Active provider, per-provider model, max_tokens (output), max_context_tokens (input budget), temperature |
server |
Host, port, CORS |
Sensitive values should be set as env vars rather than in the YAML file:
| Variable | Overrides |
|---|---|
MAILLENS_MAIL_DIR |
Mail directory mount path |
MAILLENS_GEMINI_API_KEY |
llm.gemini.api_key |
MAILLENS_ANTHROPIC_API_KEY |
llm.anthropic.api_key |
MAILLENS_OPENAI_API_KEY |
llm.openai.api_key |
MAILLENS_DB_PASSWORD |
database.password |
MAILLENS_PORT |
Host port for the web UI (default 8000) |
Set llm.active_provider in config.yaml to one of: anthropic, openai, gemini, or ollama. Since config.yaml is volume-mounted, changes take effect after docker compose restart app -- no rebuild needed.
To use a fully local setup with Ollama, set llm.active_provider: ollama in config.yaml. The configured LLM model is pulled automatically on startup -- no manual download needed.
- First-time model download -- The first startup with a new model will take several minutes while it downloads (e.g., ~5 GB for the 8B model). Subsequent startups are instant.
- First query of each session -- Will be slow (30-60s) while Ollama loads the model into GPU/RAM. After that, responses are fast.
- GPU vs CPU -- With an NVIDIA GPU, model loading and inference are dramatically faster. Without a GPU, everything runs on CPU; consider using a smaller model.
Set llm.ollama.model in config.yaml. The model is auto-pulled on first startup.
| Model | Size | VRAM needed | Notes |
|---|---|---|---|
llama3.2:1b |
1.3 GB | ~2 GB | Fastest, lowest quality. Good for CPU-only setups. |
llama3.2:3b |
2.0 GB | ~3 GB | Fast, decent quality. Recommended for CPU or low-VRAM GPU. |
llama3.1:8b |
4.7 GB | ~7 GB | Good balance of speed and quality. Recommended for GPU. |
gemma2:9b |
5.4 GB | ~8 GB | Strong instruction following. Alternative to llama3.1:8b. |
mistral:7b |
4.1 GB | ~6 GB | Fast, good at summarization. |
llama3.1:70b |
40 GB | ~48 GB | Best local quality. Requires high-end hardware. |
For 12 GB VRAM (e.g., RTX 3080 Ti), llama3.1:8b is the recommended sweet spot. For CPU-only or 4-6 GB VRAM, use llama3.2:3b or llama3.2:1b.
Each LLM provider has a max_context_tokens setting that controls how many tokens of email content are sent per query. The system:
- Estimates how many messages can fit in the budget
- Retrieves candidates via hybrid search (vector + keyword)
- Caps each message body to distribute the budget evenly
- Applies a relative score cutoff to filter noise
- Fills the context greedily in rank order until the budget is exhausted
Default context budgets reflect each provider's capabilities:
| Provider | Default context budget |
|---|---|
| Gemini 2.5 Flash | 900,000 tokens |
| Anthropic Claude | 180,000 tokens |
| OpenAI GPT | 120,000 tokens |
| Ollama (local) | 32,000 tokens |
┌─────────────┐ ┌──────────────┐ ┌────────────────┐
│ React SPA │────▶│ FastAPI │────▶│ PostgreSQL │
│ (Vite) │ │ Backend │ │ + pgvector │
└─────────────┘ └──────┬───────┘ └────────────────┘
│
┌──────┴───────┐
│ Ollama │
│ (embeddings)│
└──────┬──────┘
│
┌────────────┼─────────────┐────────────┐
▼ ▼ ▼ ▼
Claude API OpenAI API Gemini API Ollama LLM
- Discovery -- Scans the mounted Thunderbird profile for mbox and Maildir sources, deriving email account names from directory structure
- Parsing -- Reads each message, decodes MIME parts, extracts headers, handles charset edge cases and null bytes
- Cleaning -- Strips quoted reply chains, HTML tags, and signatures via
email-reply-parser - Chunking -- Splits long messages into overlapping chunks (default 512 tokens)
- Embedding -- Generates embeddings via Ollama in batches; each chunk gets its own embedding, and the message-level embedding is the average
- Storage -- Inserts messages and chunks into PostgreSQL with pgvector, with deduplication and resilient batch error handling
- Threading -- Reconstructs conversation threads using
In-Reply-ToandReferencesheaders
Retrieval uses two parallel paths that are merged and re-ranked:
- Vector path -- Cosine similarity search on chunk-level embeddings via pgvector, deduplicated to the parent message
- Keyword path -- ILIKE matching on sender, subject, and recipients for each extracted query keyword; results are ranked by how many keywords each message matches
Results are blended with configurable weights (40% vector, 60% keyword by default), filtered by a minimum score threshold, and then trimmed by a relative score cutoff (results scoring below 40% of the best match are dropped).
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite, Lucide icons |
| Backend | Python 3.12, FastAPI, SQLAlchemy (async), Pydantic |
| Database | PostgreSQL 16 with pgvector |
| Embeddings | Ollama + nomic-embed-text |
| LLM providers | Anthropic, OpenAI, Google Gemini, Ollama |
| Deployment | Docker Compose (multi-stage build) |
All models use PostgreSQL with the pgvector extension. Embeddings are 768-dimensional vectors (matching nomic-embed-text output).
Message -- one row per email:
| Column | Type | Notes |
|---|---|---|
id |
Integer PK | Auto-increment |
message_id |
String | Unique, indexed. From the Message-ID header, or a SHA-256 hash of (subject, date, sender, source_file, raw_snippet) for messages without one |
in_reply_to |
String | Indexed, used for thread reconstruction |
references |
Text | Space-separated reference chain |
thread_id |
Integer FK | Links to threads.id |
subject, sender, recipients_to, recipients_cc |
Text/String | sender and folder are indexed |
date |
DateTime(tz) | Timezone-aware UTC; descending index for sort performance |
account |
String | Derived from Thunderbird directory structure (e.g., imap.gmail.com) |
body_text, body_html, body_clean |
Text | Raw text, raw HTML, and cleaned (reply-stripped) versions |
embedding |
Vector(768) | HNSW index with m=16, ef_construction=64, cosine ops |
has_attachments |
Boolean | |
ingested_at |
DateTime(tz) | Server-side default |
MessageChunk -- one row per chunk of a long message:
| Column | Type | Notes |
|---|---|---|
id |
Integer PK | |
message_id |
Integer FK | Cascading delete; part of unique index with chunk_index |
chunk_index |
Integer | Ordering within the parent message |
chunk_text |
Text | The chunk content |
embedding |
Vector(768) | HNSW index, same params as Message |
Thread -- groups related messages:
| Column | Type |
|---|---|
id |
Integer PK |
subject |
Text |
first_date, last_date |
DateTime |
message_count |
Integer |
Source discovery (discover_mail_sources): Recursively scans the mounted profile directory. Files without extensions (and not .msf summary files) are treated as mbox. Directories containing cur/ or new/ subdirectories are treated as Maildir. Account names are derived from the Thunderbird folder hierarchy -- ImapMail/<server>/... yields the server name, Mail/<account>/... yields the account name.
Parsing robustness: A _sanitize helper strips null bytes (\x00) that cause PostgreSQL encoding errors. A _safe_charset helper falls back to UTF-8 for unrecognized charset headers. Dates are always normalized to timezone-aware UTC.
Deduplication: A seen_in_run set tracks message IDs within a single ingestion run. Combined with a database existence check, this prevents duplicates when the same email appears in multiple Thunderbird folders. On flush errors, the session is rolled back, the batch is cleared, and processing continues.
Thread building: After all messages are ingested, threads are reconstructed by following In-Reply-To and References headers. The message_id IN (...) query is batched into groups of 30,000 to stay within asyncpg's 32,767 parameter limit.
Chunking: Long messages are split with chunk_text(text, chunk_size=512, overlap=64). Token counts are approximated at 0.75 words per token. Overlap ensures context isn't lost at chunk boundaries. Each chunk is embedded independently; the message-level embedding is the element-wise mean of all non-zero chunk embeddings.
The search runs two independent SQL queries and merges results:
Vector path -- A subquery finds the best chunk-level cosine similarity per message (func.max(1 - cosine_distance)), falling back to the message-level embedding via coalesce. Fetches top_k * 3 candidates ordered by similarity.
Keyword path -- Extracts keywords from the query by tokenizing with [a-zA-Z0-9]+, lowercasing, and filtering against a ~90-word stop list (pronouns, articles, common query verbs like "find"/"show"/"summarize", and email-related terms). For each keyword, builds an or_(sender.ilike, subject.ilike, recipients_to.ilike) condition. A SQL case expression counts how many keywords each message matches:
-- Pseudocode for the generated SQL
SELECT messages.*, (
CASE WHEN (sender ILIKE '%kw1%' OR subject ILIKE '%kw1%' OR ...) THEN 1 ELSE 0 END +
CASE WHEN (sender ILIKE '%kw2%' OR subject ILIKE '%kw2%' OR ...) THEN 1 ELSE 0 END
) AS kw_hits
FROM messages
WHERE (any keyword matches)
ORDER BY kw_hits DESC, date DESC
LIMIT top_k * 5Merge and rank -- Candidates from both paths are combined into a single dict keyed by message ID. Each gets a blended score:
combined = 0.4 * vector_similarity + 0.6 * keyword_hit_ratio
where keyword_hit_ratio = matched_keywords / total_keywords. Results below the absolute threshold (default 0.08) are dropped. Then a relative cutoff removes results scoring below 40% of the best match -- this prevents low-relevance results from filling the context when only a few emails are truly relevant.
The query endpoint manages context size through a multi-stage pipeline:
-
Budget calculation (
_compute_context_budget):available_tokens = max_context_tokens - max_output_tokens - system_prompt_tokens - question_tokens - history_tokens. Tokens are estimated at ~4 characters each. The result is converted to a character budget. -
Adaptive top_k (
_estimate_top_k):k = char_budget / 2000(estimated average chars per truncated message), clamped to[10, 500]. -
Hybrid search: Fetches up to
top_kranked results. -
Thread expansion: For the top 10 results that have a
thread_id, sibling messages are fetched and appended (without displacing existing results). -
Budget trimming (
_trim_to_budget): Computes a per-message body cap:min(8000, (budget - n * 200) / n)with a floor of 500 chars. Then greedily fills the budget in rank order, truncating each body to the cap. This ensures that a few long email threads don't monopolize the budget -- with Gemini's 900K context, this typically fits 400-500 messages at ~7K chars each.
All providers extend LLMProvider and implement complete() and stream(). The base class provides:
_format_context(messages, max_context_chars)-- Formats emails as numbered[Email N]blocks with headers (From, To, Date, Subject, Folder) and body text, distributing the character budget across messages. This is what the LLM sees._context_char_budget(system_prompt, user_message, history)-- Estimates remaining char budget after accounting for non-context tokens.
Each provider builds its message array differently:
| Provider | System prompt | History format | Context location |
|---|---|---|---|
| Anthropic | system= parameter |
Alternating user/assistant messages | Prepended to the final user message |
| OpenAI | {"role": "system"} message |
Interleaved in messages array | Prepended to the final user message |
| Gemini | system_instruction in config |
types.Content(role="user"|"model") |
Prepended to the final user Content |
| Ollama | {"role": "system"} message |
Interleaved in messages array | Prepended to the final user message |
The query endpoint uses Server-Sent Events (SSE) with four event types:
| Event | Payload | When |
|---|---|---|
sources |
{type: "sources", sources: [...]} |
After retrieval, before LLM call. Each source includes sender, date, subject, account, similarity score, and a 200-char snippet. |
meta |
{type: "meta", embed_time_ms, retrieval_time_ms, context_messages, context_budget_tokens} |
After retrieval. Reports timing and how many emails were sent to the LLM. |
text |
{type: "text", content: "..."} |
As LLM tokens stream in. Each chunk is a partial text fragment. |
done |
{type: "done"} |
Stream complete. |
The frontend parses these via ReadableStream and updates the UI progressively -- source cards and a status message ("Searching emails...", "Sending N emails to LLM...") appear before the LLM's response starts streaming in.
ChatPage: Maintains a messages array of {role, content, sources, status} objects. On submit, the full conversation history (minus the current question) is serialized as conversation_history in the API request, enabling multi-turn conversations. A "New conversation" button clears the array.
InboxPage: Server-side sorting (sort_by, sort_dir query params) and pagination (page, per_page). The backend applies ORDER BY with a secondary sort on date. The frontend provides page input, first/last page, and +/-10 page jump buttons.
pip install -r backend/requirements.txt
uvicorn backend.main:app --reload --port 8000Requires PostgreSQL with pgvector and Ollama running separately.
cd frontend
npm install
npm run devVite proxies /api requests to localhost:8000.
After code changes:
docker compose build --no-cache app && docker compose upConfig-only changes (config.yaml) are volume-mounted and take effect with just:
docker compose restart app- Body keyword search -- The keyword ILIKE search currently matches against sender, subject, and recipients fields but not message body (a full-text scan of 66K+ bodies would add significant latency). Body content is covered by the vector similarity search via embeddings. This means if a contact uses an email address with no name overlap (e.g.,
prettylady99@gmail.cominstead ofsandra.buhr@gmail.com) and their name only appears in the body, the keyword path may miss it. In practice, most email clients include the display name in the sender field (e.g.,"Sandra Buhr" <prettylady99@gmail.com>), so this is rarely an issue. A future improvement could add a PostgreSQL full-text search index (tsvector) onbody_cleanfor fast body keyword matching without sequential scans.
Developed and tested against a Thunderbird profile with 4 email accounts, ~66,000 messages spanning 10+ years.
MIT