299 lines
17 KiB
Markdown
299 lines
17 KiB
Markdown
# Feature ideas — making Ultimo competitive with Gmail
|
||||
|
|
|
|||
|
|
Status (2026-10-18): **built** (uncommitted at the time of writing) — snooze,
|
|||
|
|
keyboard shortcuts, subscriptions page + unsubscribe, templates, tracker
|
|||
|
|
blocking. Left out of that first pass: follow-up reminders (§1.1), the image
|
|||
|
|
proxy (§2.1), the footer-link fallback and "auto-archive in future" filter on the
|
|||
|
|
Subscriptions page (§1.3), and bulk snooze from the list. Everything else here is
|
|||
|
|
still a proposal. Each idea notes
|
|||
|
|
what it is, why it matters, how it fits the existing architecture, and a rough
|
|||
|
|
size (S = days, M = 1–2 weeks, L = several weeks).
|
|||
|
|
|
|||
|
|
Already built, so not repeated here: undo send, scheduled send, labels, filters,
|
|||
|
|
antispam, search operators (Meilisearch), VIP contacts, calendar (CalDAV, iMIP),
|
|||
|
|
contacts (CardDAV), offline read-only mode, PWA + Web Push, 2FA, invitations,
|
|||
|
|
hosted mailboxes (Stalwart).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. What people miss when leaving Gmail
|
|||
|
|
|
|||
|
|
### 1.1 Snooze and follow-up reminders — M
|
|||
|
|
- **Snooze:** "bring this back Monday 09:00". The thread leaves the inbox and
|
|||
|
|
returns at the chosen time, marked unread and pinned to the top.
|
|||
|
|
- **Follow-up:** when sending, "remind me if nobody replies in 3 days". If no
|
|||
|
|
reply has arrived in the thread by then, it comes back to the inbox with a
|
|||
|
|
"No reply yet" chip.
|
|||
|
|
- **Fit:** a `snoozes` table (thread, user, wake_at, kind) plus an arq cron job
|
|||
|
|
that wakes due threads. Snoozed threads move to a server-side "Snoozed" folder
|
|||
|
|
so other IMAP clients see the same state. The follow-up check watches
|
|||
|
|
`message.new` events for the thread. Could be a builtin plugin.
|
|||
|
|
|
|||
|
|
### 1.2 Keyboard shortcuts — S/M
|
|||
|
|
- Gmail-compatible: `j/k` next/previous, `o`/Enter open, `u` back to list,
|
|||
|
|
`r`/`a`/`f` reply / reply-all / forward, `e` archive, `#` delete, `s` star,
|
|||
|
|
`l` label, `v` move, `/` search, `c` compose, `x` select, `Shift+I/U`
|
|||
|
|
read/unread, `g i` / `g s` go to inbox / starred, `?` shows them all.
|
|||
|
|
- **Fit:** frontend only. One central key handler that respects focus in
|
|||
|
|
inputs and the compose editor, plus a setting to turn it off.
|
|||
|
|
|
|||
|
|
### 1.3 One-click unsubscribe and a subscriptions page — M
|
|||
|
|
- An "Unsubscribe" button on mail that carries `List-Unsubscribe`, using the
|
|||
|
|
one-click POST (RFC 8058) when offered, `mailto:` otherwise.
|
|||
|
|
- A **Subscriptions** page: every sender who sends mailing-list mail, how often,
|
|||
|
|
when last read, with Unsubscribe / "Delete all from this sender" /
|
|||
|
|
"Auto-archive in future" (creates a filter).
|
|||
|
|
- **How subscriptions are found:** no service records what a user signed up
|
|||
|
|
for, so the page infers it from received mail (as Gmail does). A message is
|
|||
|
|
bulk mail when it carries `List-Unsubscribe` (required of bulk senders by
|
|||
|
|
Gmail/Yahoo since 2024), `List-Id`, `Precedence: bulk|list`, or mailing-service
|
|||
|
|
markers (`Feedback-ID`, ESP headers). A footer "unsubscribe" link is a weaker
|
|||
|
|
fallback, shown as "probably a newsletter". Mail is grouped by `List-Id`, else
|
|||
|
|
by sender address.
|
|||
|
|
- **Per group:** messages per month, last received, and **read rate** from the
|
|||
|
|
`\Seen` flag ("opened 0 of 23"), sorted most frequent / least read first.
|
|||
|
|
- **After unsubscribing:** mark the group unsubscribed with the date. If mail
|
|||
|
|
still arrives after a grace period (RFC 8058 senders should stop within 2
|
|||
|
|
days), flag "still sending" and offer block / auto-archive via a filter.
|
|||
|
|
- **Limits:** lists that haven't sent anything yet, or send no list headers,
|
|||
|
|
can't be seen. The page shows "bulk senders currently mailing you".
|
|||
|
|
- **Fit:** the plugin SDK can read raw headers
|
|||
|
|
(`backend/app/plugins/actions.py`), but it parses the stored RFC822 per
|
|||
|
|
message, and with hosted mailboxes the raw message is only cached. So extract
|
|||
|
|
`List-Id`, `List-Unsubscribe` and `List-Unsubscribe-Post` during sync into a
|
|||
|
|
small `message_list_headers` table, backfilled once for existing mail. The
|
|||
|
|
spec (§12, M3) already describes the button as an example `message.actions`
|
|||
|
|
plugin. The one-click POST goes from the server, through the SSRF guard.
|
|||
|
|
|
|||
|
|
### 1.4 Templates (canned replies) — S
|
|||
|
|
- Saved replies with placeholders (`{{first_name}}`, `{{my_name}}`), inserted
|
|||
|
|
from a compose toolbar menu or by typing `/` in the editor.
|
|||
|
|
- **Fit:** per-user table plus a compose-toolbar plugin slot.
|
|||
|
|
|
|||
|
|
### 1.5 Out-of-office reply — M
|
|||
|
|
- Start/end dates, subject, message; replies at most once per sender per N days;
|
|||
|
|
never to mailing lists or `Auto-Submitted` mail (RFC 3834).
|
|||
|
|
- **Fit:** for hosted mailboxes, write a Sieve `vacation` script through
|
|||
|
|
Stalwart. For external accounts, the outbox sends the reply from an arq job
|
|||
|
|
on `message.new`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Where Ultimo can beat Gmail
|
|||
|
|
|
|||
|
|
### 2.1 Privacy as the selling point — M
|
|||
|
|
- **Tracker blocking:** detect tracking pixels (1×1 images, known tracker
|
|||
|
|
domains, per-recipient URL tokens) and strip them even when remote images are
|
|||
|
|
allowed. Show "3 trackers blocked" on the message.
|
|||
|
|
- **Image proxy:** when remote images are allowed, load them through the server
|
|||
|
|
(SSRF-guarded, cached), so senders never see the reader's IP address or when
|
|||
|
|
the mail was opened.
|
|||
|
|
- **Aliases on the hosted domain:** unlimited addresses such as
|
|||
|
|
`shop.amazon@ultimo.email` or `name+tag@`. Each one can be switched off, and
|
|||
|
|
the app shows which alias leaked to spammers.
|
|||
|
|
- **Fit:** the sanitizer (`backend/app/indexer/sanitize.py`) already rewrites
|
|||
|
|
HTML and blocks remote images. Aliases are Stalwart principals or catch-all
|
|||
|
|
rules managed from the M6 hosted-mailbox code.
|
|||
|
|
|
|||
|
|
### 2.2 Encrypted mail (OpenPGP) — L
|
|||
|
|
- Optional, done in the browser (OpenPGP.js), so the **private key never
|
|||
|
|
reaches the server unencrypted**.
|
|||
|
|
- Decrypt and verify received PGP/MIME mail. Sign and encrypt when sending.
|
|||
|
|
Look up recipients' keys automatically (WKD, keys.openpgp.org) and publish
|
|||
|
|
WKD for the hosted domain.
|
|||
|
|
- **Fit:** compose and reading-panel plugin slots. The key backup is encrypted
|
|||
|
|
with a passphrase before upload. Indexing can't search encrypted bodies (say
|
|||
|
|
so in the UI).
|
|||
|
|
|
|||
|
|
### 2.3 AI assistant (opt-in) — L
|
|||
|
|
Detailed design in **§5** below.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Productivity polish
|
|||
|
|
|
|||
|
|
### 3.1 Split inbox / categories — M
|
|||
|
|
Tabs such as *Important / Newsletters / Notifications / Receipts / Other*,
|
|||
|
|
filled by rules (sender, `List-Id`, labels, filters) and optionally by the AI
|
|||
|
|
classifier (§5.3). The user can move a sender between tabs and the app learns
|
|||
|
|
from it.
|
|||
|
|
|
|||
|
|
### 3.2 Everything you've received — S/M
|
|||
|
|
A view listing all **attachments, links and photos**, filterable by sender, type
|
|||
|
|
and date, with preview. Builds on the right panel's Attachments tab and the
|
|||
|
|
deduplicated attachment store.
|
|||
|
|
|
|||
|
|
### 3.3 Mute a thread; send and archive — S
|
|||
|
|
- **Mute:** new replies in the thread skip the inbox (still searchable). Good for
|
|||
|
|
noisy reply-all threads.
|
|||
|
|
- **Send and archive:** one button that sends the reply and archives the thread.
|
|||
|
|
|
|||
|
|
### 3.4 Easy move from Gmail — M
|
|||
|
|
- Copy existing mail from Gmail over IMAP (Google OAuth is already planned),
|
|||
|
|
with progress, resume, and labels → labels.
|
|||
|
|
- Import Gmail filters (Gmail exports them as XML) into the filters plugin.
|
|||
|
|
- Import contacts (vCard/CSV) and calendars (ICS).
|
|||
|
|
- The hardest part of winning users is getting them to switch. This makes
|
|||
|
|
switching painless.
|
|||
|
|
|
|||
|
|
### 3.5 Calendar from mail — M
|
|||
|
|
- "Create event from this mail": date, time and place pre-filled from the text
|
|||
|
|
(rules first, AI optional).
|
|||
|
|
- "Find a time": free/busy for the thread's participants (for those whose
|
|||
|
|
calendar we can see), then send an invitation.
|
|||
|
|
- "Turn into task" once a tasks list exists (VTODO over CalDAV).
|
|||
|
|
|
|||
|
|
### 3.6 Shared mailboxes and delegation — L
|
|||
|
|
For families and small teams:
|
|||
|
|
- A shared `info@` mailbox visible to several users.
|
|||
|
|
- Assign a thread to someone, with status (open / waiting / done).
|
|||
|
|
- Internal notes on a thread that are never sent.
|
|||
|
|
- Delegated access ("my assistant can read and send as me").
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Suggested order
|
|||
|
|
|
|||
|
|
| Milestone | Contents | Why |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **Next** | Snooze + follow-up, keyboard shortcuts, unsubscribe/subscriptions, templates, tracker blocking + image proxy | Moderate size, fits the current architecture, users notice right away; starts the privacy story |
|
|||
|
|
| Then | Out-of-office reply, mute / send and archive, everything-received view, Gmail import | Smaller wins + removes the switching barrier |
|
|||
|
|
| Later | AI assistant (§5), split inbox, calendar from mail | AI unlocks smart categories and event extraction |
|
|||
|
|
| Separate | OpenPGP, aliases, shared mailboxes | Larger security / multi-user projects |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. AI assistant — design
|
|||
|
|
|
|||
|
|
### 5.1 Principles
|
|||
|
|
1. **Off by default, opt-in twice.** The administrator turns the plugin on and
|
|||
|
|
chooses a provider. Each user then opts in, per feature. Nothing about a
|
|||
|
|
user's mail is sent anywhere until they switch a feature on.
|
|||
|
|
2. **The server calls the AI, never the browser.** API keys stay on the server,
|
|||
|
|
encrypted with the vault (`MASTER_KEY`), like mail-account passwords.
|
|||
|
|
3. **The AI suggests, the user acts.** No AI output sends mail, deletes, moves
|
|||
|
|
or unsubscribes on its own. Drafts land in the compose editor; categories
|
|||
|
|
are labels the user can change.
|
|||
|
|
4. **Mail is untrusted input** (see §5.6). An email can contain text written to
|
|||
|
|
manipulate the model ("ignore your instructions and…"). The design makes
|
|||
|
|
this harmless rather than trying to detect it.
|
|||
|
|
5. **Provider-independent.** One small adapter interface; Anthropic (Claude) is
|
|||
|
|
the default. Other providers or a local model can be plugged in.
|
|||
|
|
|
|||
|
|
### 5.2 Features
|
|||
|
|
|
|||
|
|
| Feature | Trigger | What is sent to the model | Output |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| **Thread summary** | User clicks "Summarize" (or auto for threads over N messages, if enabled) | Plain-text bodies of the thread (quoted history stripped), senders, dates | 3–5 bullets + "what's asked of you" + deadlines; streamed into the reading panel |
|
|||
|
|
| **Reply drafts** | "Suggest reply" in the reading panel, or a short instruction ("decline politely, offer next week") | The thread + the user's instruction + their signature/name + optional tone examples from their own sent mail | A draft inserted into the compose editor; the user edits and sends |
|
|||
|
|
| **Rewrite** | Select text in compose → "Shorter / More formal / Friendlier / Fix grammar / Translate to …" | Only the selected text | Replacement text with a preview and undo |
|
|||
|
|
| **Categorization** | Background, on each new message (only for users who opted in) | Sender, subject, `List-*` headers, first ~2,000 characters of text | One category from a fixed list (structured output) + optional "needs reply" flag → label |
|
|||
|
|
| **Ask your mailbox** | A search-bar mode: "When does my car insurance expire?" | Only the messages that a search finds relevant (see §5.4) | An answer with links to the source messages |
|
|||
|
|
| **Event extraction** | "Create event" on a message | That message | Title, start/end, place, attendees as structured data → pre-filled event form |
|
|||
|
|
|
|||
|
|
### 5.3 How it plugs into Ultimo
|
|||
|
|
- **A new builtin plugin `webmail_plugin_ai`**, using the existing SDK:
|
|||
|
|
reading-panel and compose slots for the buttons, its own tables for settings,
|
|||
|
|
usage and cached results, and arq jobs for background work.
|
|||
|
|
- **Provider adapter:** `complete(system, messages, schema=None, stream=False)`,
|
|||
|
|
with implementations for Anthropic (official `anthropic` Python SDK), and
|
|||
|
|
later Bedrock / Vertex / a local OpenAI-compatible server (Ollama, vLLM).
|
|||
|
|
- **Streaming:** summaries and drafts stream token by token to the browser over
|
|||
|
|
the existing WebSocket (`backend/app/ws.py`), so text appears right away.
|
|||
|
|
- **Structured outputs:** categorization and event extraction use the API's
|
|||
|
|
structured output mode (`output_config.format` / `messages.parse()` against
|
|||
|
|
a JSON schema). The model can only answer with an allowed category, so a
|
|||
|
|
malicious mail can't make it invent a "trusted" label.
|
|||
|
|
- **Caching results:** a summary is stored keyed by thread + the newest message
|
|||
|
|
id, so reopening a thread costs nothing until a new message arrives.
|
|||
|
|
- **Prompt caching:** the fixed instructions (system prompt, category list,
|
|||
|
|
style guide) come first and are marked cacheable, so repeated calls pay for
|
|||
|
|
them at the much cheaper cache-read rate. Volatile parts (the email text, the
|
|||
|
|
date) come after.
|
|||
|
|
- **Bulk backlog:** classifying existing mail when a user first opts in goes
|
|||
|
|
through the Message Batches API (asynchronous, half price).
|
|||
|
|
- **Quotas and audit:** per-user monthly token budget set by the admin; a usage
|
|||
|
|
table (user, feature, model, input/output tokens) shown in the admin panel.
|
|||
|
|
Calls are logged without message content.
|
|||
|
|
- **Refusals and errors:** check `stop_reason` (including `refusal`) before using
|
|||
|
|
output; on rate limits, retry later (arq retry); the UI says "AI unavailable"
|
|||
|
|
rather than failing the mail action.
|
|||
|
|
|
|||
|
|
### 5.4 "Ask your mailbox" in detail
|
|||
|
|
Searching all mail with the model would be slow and expensive, so this uses
|
|||
|
|
retrieval:
|
|||
|
|
1. The question goes to the model together with **one read-only tool**,
|
|||
|
|
`search_mail(query, from?, after?, before?)`, backed by the existing
|
|||
|
|
Meilisearch index and run **as that user** (row-level security; it can only
|
|||
|
|
see their own mail).
|
|||
|
|
2. The model makes one or more searches; the server returns short snippets
|
|||
|
|
(sender, date, subject, ~500 characters).
|
|||
|
|
3. If needed, the model asks for the full text of a few messages with a second
|
|||
|
|
read-only tool, `read_message(id)`.
|
|||
|
|
4. It answers and cites message ids; the UI turns them into links.
|
|||
|
|
|
|||
|
|
There are no tools that write anything. Keyword search is enough to start.
|
|||
|
|
Semantic search (embeddings in pgvector) could come later, but would need a
|
|||
|
|
separate embeddings provider or local model, since that's a different service
|
|||
|
|
from text generation.
|
|||
|
|
|
|||
|
|
### 5.5 Who provides the AI
|
|||
|
|
The administrator chooses one in the admin panel:
|
|||
|
|
|
|||
|
|
| Option | Privacy | Quality | Cost | Notes |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| **Anthropic Claude API** (default) | Mail text leaves the server for Anthropic | Best | Pay per token | Simplest; official Python SDK; streaming, structured outputs, prompt caching, batches |
|
|||
|
|
| **Claude through Google Vertex AI or Amazon Bedrock** | Same model, contract and data processing with Google/AWS; regional endpoints (e.g. EU) | Best | Partner pricing | Choose if EU data residency or an existing cloud contract matters |
|
|||
|
|
| **Local model** (Ollama / vLLM on a GPU machine) | Nothing leaves your network | Noticeably weaker; fine for categorization and rewriting, weaker for summaries and Q&A | Hardware + electricity | The QNAP has no GPU — CPU-only is too slow for interactive use; needs a separate box |
|
|||
|
|
| **Bring your own key** (per user) | As the chosen provider | — | User pays | Optional; lets power users use their own account |
|
|||
|
|
|
|||
|
|
**Models (Anthropic, prices cached 2026-10-06, per million tokens in / out):**
|
|||
|
|
|
|||
|
|
| Model | Price | Suggested use |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `claude-opus-5-5` | $4 / $20 | Default for everything; best quality for drafts, summaries, Q&A |
|
|||
|
|
| `claude-sonnet-5-5` | $2 / $10 | Option for drafts / summaries if cost matters |
|
|||
|
|
| `claude-haiku-5-5` | $0.10 / $0.50 | Option for high-volume background work (categorization) |
|
|||
|
|
|
|||
|
|
The model is configurable **per feature** in the admin panel. Default to Opus 5.5
|
|||
|
|
everywhere and switch individual features down only after checking that quality
|
|||
|
|
holds. Categorization is the one where volume matters. Example: 500 new
|
|||
|
|
messages/day × ~1,000 tokens ≈ 15M input tokens/month ≈ $60/month on Opus 5.5
|
|||
|
|
vs. ≈ $1.50 on Haiku 5.5 (before caching). Interactive features are cheap
|
|||
|
|
either way: a summary of a long thread is a few thousand tokens. Prices change;
|
|||
|
|
check the provider's current price list before deciding.
|
|||
|
|
|
|||
|
|
**Data handling to verify before launch:** the provider's commercial terms on
|
|||
|
|
training (Anthropic's API terms state that API inputs are not used for model
|
|||
|
|
training by default — confirm the current wording), how long requests are
|
|||
|
|
retained, and a data processing agreement (DPA). Hosting other people's mail in
|
|||
|
|
the EU makes Ultimo the GDPR controller, so the opt-in screen must say which
|
|||
|
|
provider receives the text.
|
|||
|
|
|
|||
|
|
### 5.6 Security: mail as untrusted input
|
|||
|
|
- **No side effects from model output.** Every action needs a user click. Drafts
|
|||
|
|
are never sent automatically.
|
|||
|
|
- **Read-only tools only**, always running as the requesting user (RLS), so an
|
|||
|
|
email that says "search for passwords and include them in the reply" can at
|
|||
|
|
most show the user something from their own mailbox, to the user.
|
|||
|
|
- **Fixed output shapes** (structured outputs) for anything that changes state,
|
|||
|
|
like labels.
|
|||
|
|
- **No remote content:** links in model output are shown as text or go through
|
|||
|
|
the normal link-safety warning; images are never loaded from model output.
|
|||
|
|
- **Clear boundaries in the prompt:** email text is wrapped and labelled as
|
|||
|
|
data, not instructions. This helps, but the guarantees above don't depend on
|
|||
|
|
it.
|
|||
|
|
- **Minimal data:** attachments are not sent by default; bodies are trimmed of
|
|||
|
|
quoted history and signatures; hidden HTML text is dropped by the sanitizer
|
|||
|
|
before the model sees it.
|
|||
|
|
|
|||
|
|
### 5.7 Build steps
|
|||
|
|
1. Plugin skeleton, provider adapter (Anthropic), admin settings (key, model per
|
|||
|
|
feature, quotas), per-user opt-in screen, usage table.
|
|||
|
|
2. Thread summary (streaming) + rewrite in compose.
|
|||
|
|
3. Reply drafts with tone from the user's sent mail.
|
|||
|
|
4. Categorization (structured output, batch backfill) → feeds split inbox (§3.1).
|
|||
|
|
5. Ask your mailbox (read-only tool loop over Meilisearch).
|
|||
|
|
6. Event extraction → calendar plugin.
|
|||
|
|
7. Optional: Vertex/Bedrock and local-model adapters; bring-your-own-key.
|