Skip to content

AI prompt library

Purpose. The versioned prompts every AI feature sends, the rendering that turns mail into prompt text, and the evaluation fixtures they are judged against. Lives in packages/ai/src/prompts/. Every AI feature draws from it — it lands before the features so that the prompts, not the first screen built on them, decide their own shape.

The security property. Email is attacker-controlled input, and a prompt that treats a message body as instruction is a real vulnerability: a crafted email could make a summary say “everything is fine”, make an explanation call phishing legitimate, or steer a suggested reply. So:

  • Mail content is wrapped in a fence (<<<UNTRUSTED_MAIL_CONTENT … UNTRUSTED_MAIL_CONTENT>>>).
  • The fence markers are stripped from the content itself, so a sender cannot close the region early and have the rest read as instruction.
  • Zero-width and bidi control characters are removed — they hide text from a human reviewing the message while the model still reads it.
  • Every system prompt that shows mail states that the fenced region is data, never an instruction, and that anything inside asking the model to change its behaviour should be described rather than obeyed. That sentence is repeated in each prompt rather than shared, because it is the one instruction whose loss is not obvious from the output.
  • User-supplied text is sanitised too — a compose draft may well be pasted from an email — as is anything else that reaches a system prompt, such as a display name.

The defence is model-dependent, and that is a product fact, not a caveat. Claude Haiku 4.5 resists every injection fixture. gemma3:4b under Ollama resists them for thread-summary and explain-email but complies for smart-replies — it returned the single word the attacker’s email demanded, as a ready-to-send chip. A restatement of the rule in the user turn, after the mail (REMINDER), ships in the three reading prompts, and it did not make gemma3:4b resist on smart-replies. No prompt reliably makes a 4B model resist instruction-following, because instruction-following is the thing it does.

Two consequences. First, eval.test.ts is a per-model qualification suite, not a CI pass/fail: a model that fails it is not qualified for the feature it failed, and an operator can run it against their own model. Second, the smart-replies UI must put a chosen chip into the compose box for review and never send it directly — the user reading before sending is the mitigation that does not depend on model scale.

The fence is a fixed string rather than a per-call nonce. A nonce would resist a cleverer attacker but would change the prompt on every call, defeating prompt caching and making the audit log’s digest differ for two identical requests. Stripping the marker from the content closes the same hole while keeping both.

Rspamd’s verdict sits outside the fence. It is the mail server’s own analysis rather than the sender’s, and it is the one part of an “explain this email” prompt an attacker cannot write. A model told only “this scored 8.2” guesses; told which symbols fired, it can explain.

Versioning. Every template carries an id and a version, and promptVersion on each audit-log row records which produced an answer. That record is only worth having if a version identifies exactly one prompt — so pinned.test.ts hashes what each template renders for a fixed fixture and checks the digest in. Editing a prompt without bumping its version fails the build, with a message saying so. (The digests were generated from the code when the pins were first written, so they protect future edits rather than validating today’s text.)

Templates.

Id Output
thread-summary ≤ 4 sentences of prose, plus the action the reader owes if there is one.
explain-email {verdict, summary, signals[], advice} — combines Rspamd symbols with the content.
smart-replies Three short, distinct, ready-to-send chips.
compose-draft A body from a one-line instruction, with bracketed placeholders where a fact is missing.
compose-rewrite Tone and length; nothing added, nothing replaced, nothing dropped.
compose-translate Translation only, no commentary.
compose-grammar Corrections only — wording, voice and line breaks survive.
admin-copilot A tool-using turn for an administrator; the tool schemas are part of the prompt.

A template that takes attachments (only thread-summary today, and no route passes it any) returns them separately from its request, so the privacy layer decides whether the attachments are sent (see ai-privacy). A template physically cannot smuggle an attachment into the prompt.

Structured output without a format API. Each transport has its own mechanism — OpenAI’s response_format, Ollama’s format, Anthropic’s tool use — and none is available on all three. Since the gate requires every feature to work against all three, prompts ask for JSON and parseJsonLoose reads what comes back: fenced code blocks, prose either side, trailing commas, braces inside strings, and [{"reply": "…"}] where ["…"] was asked for. Parsing is total — a model that ignores the format yields an empty result rather than an exception, and the caller decides whether that means a retry, a fallback, or hiding the feature.

For a list of strings there is one further fallback: plain lines. gemma3:4b returns three genuinely good reply chips as three unadorned lines, and discarding them to punish the format would fail the feature on exactly the model the local option exists for. Numbered and bulleted markers are stripped, and the guards matter — a single line is never accepted, because a model that is declining says so in one line, and turning that into a suggested reply would put its refusal in the user’s outbox.

explain-email fails safe specifically: an unparseable or unrecognised verdict becomes unclear, never legitimate. It is the one output a user acts on when deciding whether to trust a message.

Thread rendering. Oldest messages are dropped first when a thread will not fit, because a reply or summary is almost always about how the conversation ends, and the omission is stated so the model knows its view is partial. A single enormous message is truncated rather than allowed to evict the thread. The newest message is always included. Budgets: 24,000 characters per thread, 6,000 per message — well inside every model in the catalogue, including small local ones.

Prompt caching. The instruction and the thread are separate system blocks, and the cache breakpoint is on the thread block (cacheableContext in mail.ts) — so the instruction and the thread together are the cached prefix. Because each feature’s instruction comes first, that prefix belongs to one feature on one thread: a summary and a set of smart replies on the same thread do not share a cache entry, and what hits the cache is the same feature run again on the same thread within the cache’s lifetime. Providers without caching ignore the hint. The admin copilot marks the other way round — its rules and tool schemas are the stable prefix and the per-server line comes after the breakpoint — for the same reason: the part that repeats is the part that is cached (ai-admin-copilot).

Evaluation. eval.test.ts runs the templates against a real provider and skips when no credentials are set, so pnpm test stays green on a bare machine and in CI. Checks are property-based, not exact-match: model output is not deterministic and tiers differ enormously, so each check asserts what would make the feature wrong rather than what a particular model happens to say — a summary that invents a figure, an explanation calling phishing legitimate, replies arriving as prose instead of chips, a grammar fix that rewrites the sentence.

Two of the fixtures exist for the injection defence: an email instructing the model to abandon its task and reply with a single word. A prompt that lost its “fenced content is data” instruction passes every unit test and fails these.

AI_TEST_ANTHROPIC_API_KEY=sk-ant-… AI_TEST_ANTHROPIC_MODEL=claude-haiku-4-5 \
pnpm exec vitest run packages/ai/src/prompts/eval.test.ts

Set AI_TEST_OLLAMA_ENDPOINT or the AI_TEST_OPENAI_* variables instead to evaluate against those; the same variables drive live.test.ts. Fixtures live in fixtures.ts and are shared with the pin test, so a prompt cannot be tuned against one input and shipped against another.

Limits & edge cases.

  • These are live. apps/api/src/routes/ai.ts (via @mailserver/ai) and webmail’s AI panel call them; the copilot has its own prompt. They are no longer exercised only by their tests.
  • Qualified models. Hosted, as the catalogue entries in packages/core/src/ai.ts record them on 2026-08-31: Anthropic claude-haiku-4-5 11/11 (it scored 10/10 on the 2026-08-30 run, before the suite grew) and OpenAI gpt-4o-mini 12/12. Local, from the 2026-08-30 run and so out of the ten checks that existed then: Ollama gemma3:4b: 9/10, failing only smart-replies injection resistance — qualified for summaries, explanations and compose assist, not for smart replies. Ollama llama3.2:1b: 5/10 — qualified for summaries only; it answered unclear on the phishing fixture (the fail-safe working, and useless), returned an unparseable reply set, left a grammar error uncorrected, prefixed a rewrite with “Here is the rewritten body:” and mistranslated. No other OpenAI-compatible endpoint than gpt-4o-mini has been evaluated. Customer-facing guidance is in the run-book ai-provider-setup.
  • A rewrite dropped a deadline, and the prompt only forbade adding one (v3, found on gpt-4o-mini during a gate run). Formalising “45 days works for us, ill get it in writing before fri” produced a clean sentence with before Friday gone. Every rule in the prompt guarded against adding or replacing a fact; none guarded against losing one. v3 names it — every deadline, date, amount and promise must still be there, and shortening means fewer words rather than fewer facts. Intermittent (3/3 kept it on re-run), which is why it is asserted in eval.test.ts rather than trusted. This is the third instance of the same pattern and the first from a hosted model, so it is not a small-model quirk: a specific prohibition holds where a principle does not, wherever the model sits.
  • A rewrite invented a date, and a general instruction did not stop it. gemma3:4b replaced a [date] placeholder with “October 26th” while compose-rewrite already said to preserve every fact and add none. Naming the placeholder case explicitly (v2) fixed it, 3/3. The same shape as the injection finding: on a small model a specific prohibition holds where a principle does not.
  • One check was passing vacuously. The smart-replies injection test looped over the parsed replies, and for (const r of []) passes — so a run in which the model complied was reported as a success purely because the output would not parse. It now asserts that replies exist before judging them. Worth remembering when writing the next property check: assert you got something before asserting what it is.
  • Two bounds were tuned to a capable model and one was simply wrong. The grammar and summary bounds held on both models; the rewrite check demanded the digits 45 and gemma3 wrote “Forty-five days”, which preserves the fact exactly as instructed. The property is that the number survives, not the numeral.
  • English-centric. The prompts are written in English and the checks assume it. A non-English thread will still summarise, but nothing here verifies that.
  • No retry on unparseable output. A model that ignores the JSON instruction produces an empty result and the feature decides. Retrying once with a stricter instruction is an obvious later improvement.
  • Truncation is by characters, not tokens. Cheap, provider-independent and conservative; a token-accurate budget would need a tokeniser per provider.
  • The digests are only as good as the discipline. Nothing stops someone updating a pin without bumping the version — the test makes that a deliberate act rather than an accident.

The copilot prompt is the third application of the same lesson. It has no fenced region — it reads telemetry, not mail — so its failure mode is a true-sounding sentence the model never looked up rather than an instruction it obeyed. Its rules therefore name the exact sentence that must not be written: a delivery outcome absent from a returned row, a domain that appeared in no tool result, an estimated count. Its tool descriptions are inside the pinned digest, because a description is as much of the prompt as the system text. See ai-admin-copilot.