gavtaylor / retentionpolicy
Outlook-style per-folder retention policies: automatically delete or archive messages older than a set period, applied nightly on the Dovecot server
Package info
github.com/gavtaylor/roundcube-retentionpolicy
Type:roundcube-plugin
pkg:composer/gavtaylor/retentionpolicy
Requires
- php: >=8.1.0
- roundcube/plugin-installer: ~0.3.5
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Roundcube webmail plugin that adds Outlook-style per-folder retention policies. You can automatically delete or archive messages in a folder once they're older than a set period, and the policies are applied nightly on the Dovecot mail server.
This project was built using agentic AI development (Claude Code). We're disclosing this up front because we know not everyone's comfortable with AI-authored code in something that deletes mail. Review it accordingly before you install it.
Status
Working, verified end to end on one live instance (Roundcube 1.7.4, Dovecot 2.3.16, Elastic skin, contextmenu plugin), using disposable test folders filled with copies of real mail. Real mail was never the test subject.
What was checked from the browser:
- the right-click and folder-actions entries, including that right-clicking a folder other than the open one configures the folder you clicked;
- the dialog's field toggling and its live preview count;
- inheritance, including "keep forever" stopping a parent policy;
- the Trash-specific options;
- the Settings → Folders section, including a rename (policies followed the folder and its subfolders) and a delete (policies removed).
The nightly runner was checked with a dry run and then a real run. It moved exactly what the dry run predicted (old mail to Trash and to Archive), left mail newer than the cutoff and a "keep forever" subfolder alone, and logged each action. Mail moved into Trash was confirmed to take a fresh saved date, so a Trash policy doesn't remove it the same night.
The helper's own guards were each tested: it refuses a bad username, a missing folder, a destination that's the same as the source, an unknown date basis, and a cutoff newer than 7 days.
Flagged messages (v0.2.0) were tested on a throwaway folder of 20 old messages, 3 of them flagged:
- the dialog's preview showed 17 to move and "3 flagged message(s) will be kept", and 20 with the toggle off;
- the Settings → Folders toggle saved both ways;
- the runner's dry run planned 17;
- given all 20 message IDs, the helper moved only the 17 unflagged ones, so a flagged message is protected server-side even if a caller asks for it.
Older callers that don't pass the new optional keep-flagged argument still
work with the new helper.
Unread messages (v0.3.0) were tested the same way: 20 old messages, 5 unread and 3 flagged, one of them both.
- The preview showed 17 to go with flagged only, 15 with unread only, and 13 with both on ("7 flagged or unread message(s) will be kept").
- Both toggles saved from the dialog and from Settings → Folders.
- The runner planned 13 with both options on.
- Given all 20 message IDs with both options, the helper moved only the 13 read, unflagged ones.
- The helper rejects any option other than
keep-flaggedandkeep-unread.
Bugs found and fixed while testing:
- The dialog had a horizontal scrollbar, because Elastic turns a propform table into Bootstrap rows with negative margins. It now uses core's own dialog markup.
- The dialog didn't grow when rows appeared, so the preview was squeezed into a scroll strip.
- Archive was offered on Trash.
- An empty label rendered as a raw
[retention_hint]in Settings.
Why
Roundcube has no way to expire mail by age in an arbitrary folder. The closest
existing plugin, roundcube-toolbox's purge tool (successor to the abandoned
purge plugin), only empties Trash and Junk. Dovecot's own autoexpunge is
set per folder name in server config, for every account at once, so users
can't change it themselves.
What you can set
Right-click a folder (with the contextmenu plugin), use the folder-actions menu under the folder list, or edit the folder under Settings → Folders. All three open the same options:
- Policy
- Use parent folder's policy. This is the default. Subfolders inherit the nearest parent's policy, as in Outlook.
- Delete messages older than the period, either moved to Trash (recoverable) or permanently.
- Archive messages older than the period. They go to the archive folder,
organised the same way as the core archive plugin's Archive button (by
year, month, original folder, etc., using its
archive_typesetting). - Keep forever. Shown when there's a parent policy to override; stops it applying here and in this folder's subfolders.
- Older than: 1 week, 2 weeks, 1, 2, 3 or 6 months, or 1, 2, 5 or 10 years.
- Flagged messages: kept by default. A flagged (starred) message is never deleted or archived, whatever the folder's policy says. This is the per-message "never delete" override, like Outlook's per-item retention tag. A folder full of notifications can expire everything while you star the odd one worth keeping, from webmail or any other mail app, since flags sync over IMAP. Turn it off for a folder if flags there shouldn't count.
- Unread messages: off by default. Turn it on and an unread message is never deleted or archived, so a folder only clears what you've actually read. It's off by default so that adding the option never quietly changed what an existing policy removes.
The dialog shows a live count of how many messages the policy would affect if it ran tonight, and how many flagged and/or unread messages it's keeping.
Outlook/Purview also has a "Retain" policy, meaning mail that can't be deleted. It isn't offered here: any IMAP client can still delete mail, so a webmail plugin can't enforce it.
How messages are aged
- Most folders count from when the server received a message, not the
Date:header, which the sender controls. - Trash (and folders inside it) count from when a message was moved into Trash, like Outlook's Deleted Items. Otherwise mail that another policy moved to Trash would already look old, and a Trash policy would permanently delete it the same night, so "move to Trash" wouldn't be recoverable at all.
- A delete-to-Trash policy on Trash itself means permanently delete.
- Archive policies have no effect inside the archive folder, just as the Archive button skips mail that's already archived.
How it works
Roundcube plugin (this repo)
→ stores each folder's policy in the user's Roundcube preferences
→ preview: asks helper/rc-retention (via sudo) how many messages match
bin/retention-run.php (nightly, systemd timer)
→ reads every user's policies from Roundcube's database
→ for each folder: works out its effective policy (own or inherited)
→ helper/rc-retention scan / move / expunge, via doveadm
The helper does its own validation as well as the caller's. It re-applies the
age filter on the server for every move or expunge, and refuses any cutoff less
than 7 days old. See helper/README.md.
Installation
-
Set up the mail-server side (helper, sudoers rule, timer) per
helper/README.md. -
Install the plugin:
cd /path/to/roundcube composer require gavtaylor/retentionpolicy -
Add
retentionpolicyto$config['plugins'], copyconfig.inc.php.disttoconfig.inc.phpin the plugin directory, and setretentionpolicy_helper.
Tests
php tests/engine_test.php
This covers the policy logic (inheritance, Trash/Archive special cases, archive layouts, cutoff dates, renames) without needing Roundcube or a mail server.
License
GPL-3.0-or-later, matching Roundcube core and the wider plugin ecosystem it integrates with.