mkerstner / write-assistant
Write, rewrite and proofread e-mail and summarise received messages with a local LLM served by Ollama. All model calls are made server-side to an allow-listed endpoint, so no mail content is sent to third parties.
Package info
github.com/mkerstner/roundcube-plugin-write-assistant
Type:roundcube-plugin
pkg:composer/mkerstner/write-assistant
Requires
- php: >=8.1
- roundcube/plugin-installer: ~0.3.5
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Write, rewrite and proofread e-mail, and summarise received messages, with a local LLM served by Ollama. All model calls are made server-side by PHP to an allow-listed endpoint; the browser never talks to Ollama and no mail content is sent to OpenAI or any other third party.
Built for CPU-only servers: answers stream in as they are generated, summaries never block opening a message and give way to compose requests, and requests nobody waits for any more are cancelled.
Features
- Write an e-mail from instructions in any language, in one of 36 output languages, with a style (assertive, casual, enthusiastic, funny, informational, professional, urgent, witty), a length (short / medium / long) and a creativity setting (mapped to the model temperature). Optionally uses the message being replied to as context and suggests a subject (only filled in when the subject field is empty).
- Rewrite the selected text or your own part of the draft in another style, length or language.
- Correct spelling, grammar and punctuation without changing the wording.
- Context menu on selected text (right-click): Correct, Improve writing,
Shorten, Expand, Change tone ▸, Translate ▸, and the full dialog. The
result is shown first and replaces the selection on click. Right-click
without a selection, or Ctrl+right-click, keeps the browser's menu with
its spelling suggestions. Quick actions are configurable
(
write_assistant_quick_actions). - Works with the plain-text editor and the HTML (TinyMCE) editor. Signature, reply intro, quoted text and forwarded message are left alone; the result is shown first and only enters the message when you click Insert/Replace (undo works). In HTML drafts, Rewrite/Correct return plain paragraphs; the dialog warns when that would drop formatting.
- Summaries in the user's Roundcube language above an opened message (automatic after a short delay, or on click), and on hover over a subject in the message list (by default only summaries that already exist, so hovering never starts the model). Cached per message.
- Admin-customisable prompts, styles, lengths, languages and models.
- Per-user switches in Settings > Preferences ("Composing Messages",
"Displaying Messages"), enforceable via
dont_override.
Requirements
- Roundcube 1.6 or later (developed against 1.7.5), PHP 8.1+ with cURL.
- Elastic or a skin that extends it (e.g. Aurora) for the toolbar icon; everything else is skin-independent.
- Ollama on the Roundcube server itself (the default; see below), with a
model pulled, e.g.
ollama pull gemma4:12b. About 10 GB of free RAM for a 12 B model on CPU.
Installing Ollama on the Roundcube server
By default the plugin expects Ollama on the same machine as Roundcube, at
http://localhost:11434, and only allows loopback addresses. Ollama itself
listens on 127.0.0.1 by default, so it is not reachable from the network
and mail content never leaves the server. Keep it that way: do not set
OLLAMA_HOST.
On Linux (see https://docs.ollama.com/linux for other setups):
curl -fsSL https://ollama.com/install.sh | sh # installs the "ollama" service ollama pull gemma4:12b sudo systemctl status ollama
Recommended service settings for a CPU-only server
(sudo systemctl edit ollama, then sudo systemctl restart ollama):
[Service] Environment="OLLAMA_NUM_PARALLEL=1" Environment="OLLAMA_MAX_LOADED_MODELS=2"
If Roundcube runs in a container, localhost is the container, not the
host: either run Ollama in the same container network (and point
write_assistant_url at it), or use the host's address, e.g.
http://host.docker.internal:11434, and add that name to
write_assistant_allowed_hosts.
Installation
A. Via the Roundcube plugin installer (recommended)
Run from the Roundcube root:
composer require mkerstner/write-assistant
The roundcube/plugin-installer (declared as a dependency) then:
- installs the plugin into
plugins/write_assistant/(Composer package names use a hyphen, Roundcube plugin names an underscore), - copies
config.inc.php.disttoconfig.inc.phpif no config exists yet, - adds
write_assistantto thepluginslist inconfig/config.inc.php(when that file is writable and Composer runs on the CLI), - refuses to install on Roundcube older than
min-version(1.6.0).
B. Manual installation
- Put this directory at
plugins/write_assistant/. - Optional: copy
config.inc.php.disttoconfig.inc.phpand change what you need (models, prompts, ...). Every option has a documented default inconfig.inc.php.dist, which matches a same-host Ollama withgemma4:12b. - Add
'write_assistant'to$config['plugins']inconfig/config.inc.php.
Where settings go
Put plugin settings into plugins/write_assistant/config.inc.php. Roundcube
loads that file after config/config.inc.php, so its values win. The plugin
installer creates it as a full copy of config.inc.php.dist, which means a
write_assistant_* option set only in config/config.inc.php is ignored.
Either change the option in the plugin's config.inc.php, or delete that
file and keep the settings in config/config.inc.php (Roundcube then also
reads config/write_assistant.inc.php). The health check below shows which
file each model setting comes from.
Check the setup
php plugins/write_assistant/bin/check.php --generate
It verifies the endpoint allow-list, that Ollama answers, that every
configured model is installed and that temp_dir is writable, and with
--generate times a tiny request per model. Run it with the command-line
PHP your Roundcube uses: on shared hosting, php in the shell is often an
older version or the CGI variant (the script says so), and the right binary
is something like php8.3 or /usr/local/bin/php83.
To use an Ollama on another host instead, set write_assistant_url and add
that host to write_assistant_allowed_hosts. Ollama has no
authentication: never expose it beyond the Roundcube server without a
firewall, or put it behind an authenticating reverse proxy and set
write_assistant_headers.
Ollama on a CPU-only server
- Keep
write_assistant_num_ctxthe same for all models: Ollama reloads a model whenever the context size changes. - A smaller summary model is several times faster, e.g.
write_assistant_models = ['compose' => 'gemma4:12b', 'summary' => 'gemma4:e4b-it-qat']. Let Ollama keep both in memory (OLLAMA_MAX_LOADED_MODELS=2, see above) and keep a longwrite_assistant_keep_alive(loading takes 25–40 s on CPU). - Leave
OLLAMA_NUM_PARALLELat 1: requests are served one after another, and the plugin makes summaries give way while someone is composing (via flag files in Roundcube'stemp_dir, so this works per Roundcube host).
Streaming behind a reverse proxy
Generated text and summaries are sent as newline-delimited JSON. The plugin
disables PHP output buffering and compression for these responses and sends
X-Accel-Buffering: no (nginx). With other buffering proxies, or if text
only appears at the end, set write_assistant_stream to false.
The plugin sends a heartbeat line every 5 s while the model is still reading
the prompt, which keeps proxy read timeouts from firing and lets PHP notice
when the browser has gone away (it then cancels the model run). Raise
write_assistant_timeout and PHP-FPM's request_terminate_timeout if
answers take longer than 3 minutes. Keep nginx's
fastcgi_ignore_client_abort off (the default), so closed browser
connections reach PHP.
Troubleshooting
The messages users see are deliberately generic. The technical reason is
logged to Roundcube's logs/errors.log (lines starting with
write_assistant:), and php plugins/write_assistant/bin/check.php,
run on the Roundcube server, tests the same connection the plugin uses.
- "The assistant is not reachable right now": PHP cannot connect to
write_assistant_url.localhostmeans the machine (or container) Roundcube runs on, not your desktop. Ollama listens on127.0.0.1only by default: to serve another host, start it withOLLAMA_HOST=0.0.0.0:11434, restrict access with a firewall, pointwrite_assistant_urlat it and add its name or IP towrite_assistant_allowed_hosts. From a Docker container, the Docker host is usuallyhost.docker.internal. - "The configured AI model is not available on the server": run
ollama pull <model>on the Ollama host, or fixwrite_assistant_models. - "The assistant is not configured correctly": the URL's host is not
in
write_assistant_allowed_hosts.
Privacy and security
- Requests go only to hosts in
write_assistant_allowed_hosts; redirects are never followed and HTTP proxies are never used, even if Roundcube'shttp_clientconfig or the environment defines one. - Ollama "cloud" models (
gemma4:cloud,…-cloud) are refused: Ollama would forward their requests, and the mail content, to ollama.com. - Endpoints are protected by Roundcube's request token (CSRF).
- Mail content is passed to the model as tagged data, separate from the admin's prompt, and output is always rendered as text. Prompt injection from incoming mail cannot be ruled out with small models, but the model has no tools or actions and its output is only displayed or suggested.
- Per-user rate limit (
write_assistant_rate_limit); logs never contain mail content (write_assistant_debuglogs only timings and token counts).
Tests
PHPUnit tests live in tests/ and run like Roundcube's core plugin tests,
from a Roundcube checkout with its test setup (test database configured in
tests/config/; the test bootstrap recreates that database):
vendor/bin/phpunit -c tests/phpunit.xml plugins/write_assistant/tests
tests/cli_smoke.php runs live checks against a model, including a
summary of a prompt-injection e-mail; useful to compare candidate models:
php plugins/write_assistant/tests/cli_smoke.php vendor/autoload.php gemma4:e4b-it-qat
License
Apache License, Version 2.0. See LICENSE.