- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings section, mail templates (Compose > More > Templates), a Subscriptions folder with bulk unsubscribe/delete, and tracker blocking with a Privacy toggle (on by default) - Own dialogs instead of browser popups; emoji picker in compose; compose windows survive a visit to Settings - Sentence completion learned from the user's sent mail (no AI) - Interface in English and Polish (one file per language, English as the fallback), picked from the browser on first run, then kept - Email translation on the server via LibreTranslate: Translate button and "Translate to...", messages in several languages translated part by part, names/addresses/emoji/file names/phone numbers kept, an animated line while translating - Antispam "Mark as spam" loads with the message, not when the menu opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 KiB
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
snoozestable (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 watchesmessage.newevents for the thread. Could be a builtin plugin.
1.2 Keyboard shortcuts — S/M
- Gmail-compatible:
j/knext/previous,o/Enter open,uback to list,r/a/freply / reply-all / forward,earchive,#delete,sstar,llabel,vmove,/search,ccompose,xselect,Shift+I/Uread/unread,g i/g sgo 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 byList-Id, else by sender address. - Per group: messages per month, last received, and read rate from the
\Seenflag ("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 extractList-Id,List-UnsubscribeandList-Unsubscribe-Postduring sync into a smallmessage_list_headerstable, backfilled once for existing mail. The spec (§12, M3) already describes the button as an examplemessage.actionsplugin. 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-Submittedmail (RFC 3834). - Fit: for hosted mailboxes, write a Sieve
vacationscript through Stalwart. For external accounts, the outbox sends the reply from an arq job onmessage.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.emailorname+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
- 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.
- The server calls the AI, never the browser. API keys stay on the server,
encrypted with the vault (
MASTER_KEY), like mail-account passwords. - 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.
- 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.
- 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 (officialanthropicPython 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(includingrefusal) 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:
- 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). - The model makes one or more searches; the server returns short snippets (sender, date, subject, ~500 characters).
- If needed, the model asks for the full text of a few messages with a second
read-only tool,
read_message(id). - 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
- Plugin skeleton, provider adapter (Anthropic), admin settings (key, model per feature, quotas), per-user opt-in screen, usage table.
- Thread summary (streaming) + rewrite in compose.
- Reply drafts with tone from the user's sent mail.
- Categorization (structured output, batch backfill) → feeds split inbox (§3.1).
- Ask your mailbox (read-only tool loop over Meilisearch).
- Event extraction → calendar plugin.
- Optional: Vertex/Bedrock and local-model adapters; bring-your-own-key.