plead is an open-source MIT-licensed PHP command-line tool written and maintained by the Ad Noctem Collective for managing Plesk-hosted mail resources that Plesk's own UI and XML-API don't handle natively.
Three things are currently automated:
- Auto-replies with a scheduled start time. Plesk supports
end_datenatively, butstart_datedoes not exist as a Plesk concept.pleadenforces it: you schedule a reply with a start and end date, and thewatchdaemon pushes it to Plesk the moment the start time is reached. - Mail distribution groups (e.g.
all@company.com) β add/remove recipients without hand-editing Plesk's forwarding UI. Awatchdaemon continuously converges the server state toward whatpleadhas been told. - Rule-driven groups β a group's recipients can be derived from a regex over the domain's addresses
(
mail.groupconfig ormail:group:set --rule), with manual recipients appended (even foreign addresses). The watcher re-derives the list every pass, so new mailboxes join automatically whileinfo@,pbx@and friends stay out.
plead runs entirely off the Plesk box and talks to Plesk exclusively over HTTPS XML-RPC (port 8443) using a secret key or administrator credentials. It is safe to run against production mail infrastructure: development follows read-before-write, --dry-run is enforced structurally in the gateway, reconciliation is idempotent, and every action lands in a local SQLite audit trail.
One installation can manage several Plesk servers; a global --server flag picks one (default: the first configured).
The local SQLite database is the desired state; the Plesk server holds the live state. Every mutation follows the same three steps:
- Record the intent locally first β the change lands in SQLite (and the
sync_logtable gains apendingentry) before anything touches the network. - Apply to Plesk (or
--dry-run). - Finalize β on success the entry is marked
reconciledand the log entry resolves took; on failure the entry stayspending(the watcher retries) and the log entry carrieserror:<message>.
Rows are never deleted: auto-replies carry a status (scheduled/disabled) and recipients are soft-deleted, so the database doubles as a full audit trail of every intent, even months later. Read commands show the live server state by default; pass --local to inspect the desired state instead.
Three ways to run plead, all behaving identically:
- Standalone phar (any platform, no install) β download
plead.pharfrom the latest release,chmod +xit and run./plead.phar list(Windows:php plead.phar). The phar is self-contained: config, SQLite state and logs live in the usual XDG locations, never inside the archive. It needs PHP 8.4.27+ (or 8.5.12+) withext-ctype,ext-curl,ext-dom,ext-iconv,ext-pdo_sqlite,ext-phar,ext-simplexml,ext-xmlandext-zlib(the embedded requirement checker enforces these);ext-pcntlis additionally required forwatch -d.plead.phar --versionreports the git tag (or commit hash) it was built from. - Docker β
docker pull ghcr.io/adnoctem/plead(image tags cover the PHP 8.4/8.5 variants);PLEAD_DATA_DIR=/datais the persistent volume. The image runs as UID 1001, so bind-mounted state directories must be writable by that user. - From source β
composer installin a checkout;bin/pleadruns directly on PHP 8.4.27+ (or 8.5.12+).
PHP security floor: PHP2Shell (CVE-2026-109618 / CVE-2026-109619) is fixed in PHP 8.4.27 and 8.5.12.
pleadis a CLI and never serves HTTP requests, but it requires a patched runtime regardless β older patch releases are unsupported.
# configure once (either works)
plead config:set servers.0.host mail.company.com
plead config:set servers.0.secret_key <secret-key>
plead config:edit # open the config file in $EDITOR instead
# see the resolved configuration (post-merge, credentials masked) and where files live
plead config:list
plead config:path
# schedule an auto-reply (the configured template renders the message;
# --data-file sources template variables from a JSON or YAML file)
plead mail:autoresponder:set user@company.com \
--data-file=~/vacation-data.yaml \
--start-date="2026-08-20 08:00" \
--end-date="2026-08-30 18:00" \
--subject="Automatische Antwort" --format=html
# dry-run first β no network calls are made at all
plead mail:autoresponder:set user@company.com --data-file=~/vacation-data.yaml \
--start-date="2026-08-20 08:00" --end-date="2026-08-30 18:00" --dry-run
# what is configured live on the server vs. what plead intends to converge toward
plead mail:autoresponder:get user@company.com
plead mail:autoresponder:get user@company.com --local
# disable an auto-reply again (kept in the audit trail)
plead mail:autoresponder:set user@company.com --enabled=false
# manage a mail distribution group
plead mail:group:add group@company.com newhire@company.com
plead mail:group:remove group@company.com leaver@company.com
plead mail:group:set group@company.com --recipients=a@company.com,b@company.com
plead mail:group:set all@company.com --rule='^(1und1|info|support|noreply|pbx)@'
plead mail:group:set all@company.com --rule='^(info|noreply)@' --recipients=consultant@external.com
plead mail:group:set all@company.com --rule='^(info|noreply)@' --write-config # keep it maintained by the watcher
plead mail:group:get group@company.com # live state on the Plesk server
plead mail:group:get group@company.com --local # desired state + history
# one watcher for everything: auto-replies, groups, aliases and rule-driven lists
plead watch --interval=60 # foreground
plead watch -d # detached: releases the terminal prompt
# target a specific server when several are configured
plead --server prod.example.com mail:group:list
# mailbox operations
plead mail:address:list --domain=company.com
plead mail:address:set user@company.com --description="Holiday replacement"
plead mail:address:set user@company.com --outgoing-limit=250
plead mail:address:set user@company.com --quota=512
plead mail:address:password user@company.com --generate
plead mail:address:rename user@company.com newuser
# aliases (additional addresses delivering into a mailbox)
plead mail:alias:add user@company.com info@company.com
plead mail:alias:remove user@company.com sales@company.com
plead mail:alias:set user@company.com --aliases=info@company.com,sales@company.com
plead mail:alias:list
# domains
plead domain:list
plead domain:get example.com
plead domain:set example.com --description="Main company domain"Read commands query the live Plesk server by default; add --local to read the SQLite desired state instead.
| Command | Purpose |
|---|---|
mail:group:list |
List mail groups (live: mailnames with forwarding; --local: managed) |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
mail:group:set <group> |
Replace the full recipient list (--recipients=a@b,c@d and/or --rule=<pcre>; falls back to the configured mail.group entry) |
mail:group:add <group> <recipient> |
Add one recipient |
mail:group:remove <group> <recipient> |
Remove one recipient (soft-delete, keeps history) |
mail:address:list |
List all mail addresses (--domain=, --local) |
mail:address:get <email> |
Mailbox, forwarding and auto-reply state of an address |
mail:address:set <email> |
Set properties: --description=, --outgoing-limit=, --quota= (MiB), --antivir=; missing quota/antivir fall back to mail.defaults |
mail:address:remove <email> |
Remove the mail address |
mail:address:password <email> |
Set (--password=) or rotate (--generate) the mailbox password |
mail:address:rename <email> <name> |
Rename a mail account (local part only; mailbox settings are kept) |
mail:address:export |
Export all addresses (`--format=csv\ |
mail:alias:list |
Mailboxes with aliases (live: on the server; --local: managed) |
mail:alias:get <email> |
Aliases of a mailbox (--local adds desired state + removal history) |
mail:alias:set <email> |
Replace the full alias list (--aliases=a@b,c@d) |
mail:alias:add <email> <alias> |
Add one alias address |
mail:alias:remove <email> <alias> |
Remove one alias address (soft-delete, keeps history) |
mail:autoresponder:list |
All addresses with an enabled auto-reply (--local: scheduled) |
mail:autoresponder:get <email> |
Auto-reply of an address (--local: desired state) |
mail:autoresponder:set <email> |
Enable/schedule (--template, --data-file, --start-date, --end-date, --inclusive, --subject, --format), disable, or preview (--preview) |
watch |
Continuously converge everything (auto-replies, groups, aliases, rule-driven lists; --interval, --full, -d/--detached) |
domain:list |
List all domains on the server |
domain:get <domain> |
Everything plead can read about a domain (hosting, limits, mail count) |
domain:set <domain> |
Set properties (--description=, --status=enabled or disabled, `--type=virtual-host |
domain:add <name> |
Create a domain (--type=virtual-host or forwarding or frame-forwarding or none, --parent=, --dest-url=, --property=key:value) |
domain:remove <domain> |
Remove a domain from the server |
domain:traffic:get <domain> |
Traffic usage in a window (--from=, --to=, YYYY-MM-DD) |
domain:traffic:set <domain> |
Record traffic counters manually (--date=, --smtp-in=, ...) |
domain:descriptor <domain> |
Hosting settings descriptor (property names, types, defaults) |
config:get <dotted.key> |
Show the resolved value (secrets masked; --expose-secrets bypasses) |
config:set <dotted.key> <value> |
Write a configuration key to the user config file |
config:list |
Show the resolved configuration with secrets masked |
config:edit |
Open the user config file in $EDITOR and validate it afterwards |
config:path |
Show where plead stores configuration, data, and logs |
template:render |
Render one template to MJML, HTML or text |
template:serve |
Hot-reloading local template studio |
db:path |
Show the location of the SQLite database |
db:query |
Open an interactive sqlite3 shell on the database |
server:info |
Server identity, versions, object counts, resources and update status |
server:session:list |
Currently opened control-panel sessions |
server:session:get <session-id> |
One session (the API has no single-session read; the list is filtered) |
server:session:terminate <session-id> |
Close a control-panel session |
server:admin |
Plesk Administrator personal information |
server:service:status [service] |
Service states (all, or one id like web, smtp, dns) |
server:service:start <service> |
Start a server service |
server:service:stop <service> |
Stop a server service |
server:service:restart <service> |
Restart a server service |
server:ip:list |
All IP addresses on the server |
server:ip:get <ip> |
One IP address (list filtered client-side) |
server:ip:add <ip> |
Add an IP (--netmask=, `--type=shared\ |
server:ip:set <ip> |
Set IP properties (--type=, --public-ip=) |
server:ip:remove <ip> |
Remove an IP address from the server |
server:components:list |
Installed Plesk components |
server:components:install <id> |
Install a component (--update-id=; docs shape, live validation pending) |
server:extension:list |
Installed extensions |
server:extension:get <id> |
One extension by id |
server:extension:install <id> |
Install an extension by id or --url= |
server:extension:uninstall <id> |
Uninstall an extension |
server:extension:call <id> <op> |
Call an XML API operation of an ApiRpc extension (--param=name:value; e.g. git: get/create/update/remove/deploy/fetch) |
server:ref [id] |
REST CLI-gate: available CLI ids (no arg) or the reference of one id |
server:exec <id> <args...> |
Execute a Plesk CLI command via the REST CLI-gate (args after --, e.g. extension -- --call sslit --help; --no-fail-on-error) |
audit:trail |
Browse the audit trail (interactive TUI on a TTY, plain table otherwise; --resource=, --result=, --limit=) |
audit:export |
Dump the entire audit trail (incl. change details) to a file (--format=json or yaml, -o/--output) |
Global options (available on every command):
| Flag | Behavior ||
| --------------- | -------------------------------------------------------------------------- ||
| -c, --config | Load only this config file; skip discovery and merging ||
| --server | Operate on this server (host or index; default: the first) ||
| --write-config| Persist the change to the config file (mail.group definitions) ||
| --dry-run | Log mutations without performing them - no network calls at all ||
| --log-level | Baseline file log level (default info); -v/-vv/-vvv also raise it ||
| -v/-vv/-vvv | Console verbosity (drives both console output and file log level) ||
Configuration is discovered from per-user and system-wide locations, in most-specific-first order, and merged with the user's files winning:
| OS | User (wins) | System-wide fallback |
|---|---|---|
| Linux | $XDG_CONFIG_HOME/plead (~/.config) |
/etc/plead |
| macOS | ~/Library/Application Support/plead |
/Library/Application Support/plead |
| Windows | %LOCALAPPDATA%\plead, %APPDATA%\plead |
%ProgramData%\plead |
plead.yaml and plead.json are both supported (YAML preferred). One installation can manage several Plesk servers; servers is a list, a per-server section (named by host) overrides the general mail config for that server, and the first server is the default unless --server says otherwise.
servers:
- host: plesk.example.com
secret_key: <key>
- host: prod.example.com
login: admin # or credentials instead of a secret key
password: <password>
default_server: prod.example.com # used when neither --server nor PLEAD_SERVER is given
watch:
interval: 60 # default interval of `plead watch` (--interval overrides)
# optional: ingest absence JSON files dropped by external tools
# (directory scripts, HR exports, ...) - disabled unless `paths.root` is set
inbox:
paths:
root: /srv/plead/inbox # watched for *.json; processed/ and failed/ live inside
# processed: done # optional (default: <root>/processed)
# failed: failed # optional (default: <root>/failed)
# template: resources/templates/autoresponder.txt.twig # default: template.paths.auto_reply
# translations: resources/translations # default: template.paths.translations
domain: company.com # mail domain for entries that use samAccountName
timezone: Europe/Berlin # optional; defaults to the process TZ (match the Plesk server!)
# data_file: /etc/plead/absence-data.yaml # baseline template variables
# language: de
# subject: "Automatische Antwort"
# format: plain # plain|html; default: inferred from the rendered message
# inclusive: true # end date counts as absence (template wording)
# optional: template + translation catalog locations (defaults shown)
template:
paths:
auto_reply: resources/templates/autoresponder.txt.twig
translations: resources/translations
organization: "Ad Noctem Collective" # optional: footer name
urls: # optional: logo + social icons (map order)
logo: "https://example.com/logo.png"
# github: "https://github.com/adnoctem"
# x: "https://x.com/adnoctemcollective"
# web: "https://adnoctem.co"
language: de # optional: render language for single-language templates
# general config applying to every server
mail:
defaults:
quota: 512MB # parsed to bytes; also accepts 1GB, 2 GB, 1048576
antivirus: off # off|in|out|inout; default quota/antivirus for mail:address:set
group:
- address: all@example.com
pattern: "^(1und1|payments|info|support|shop|noreply|marketing|bestellungen)@"
- address: group2@example.com
recipients:
- user1@example.com
- user2@example.com
autoresponder:
- address: user@example.com
data_file: /etc/plead/replies/vacation-data.yaml # optional: JSON/YAML template variables
start_date: "2026-08-20T08:00:00+02:00" # optional; quote to keep the offset
end_date: "2026-08-30T18:00:00+02:00"
inclusive: false # optional: whether the end date is vacation
subject: "Automatische Antwort" # optional: reply subject (default: Re: <request_subject>)
format: html # optional: plain|html (default: server default)
# per-server overrides: this section only applies to plesk.example.com
plesk.example.com:
mail:
group:
- address: group@example.com
pattern: "^(info|support)@"Group entries define the desired recipients of a mail group:
patternβ a PCRE (delimiters optional) of addresses to exclude from the list. The list holds every mailbox on its domain except those matching, and the list itself is never its own recipient. Entries with a domain-less address need an explicitdomain:.recipientsβ manual recipients appended to the pattern-filtered set. They may point at any address, including domains Plesk does not manage (e.g. a consultant's mailbox at an external provider) β and a manual entry wins over the exclusion pattern. Without apattern, the manual list is the whole set.- An entry with neither is a configuration error;
patternandrecipientsfreely combine.
watch re-derives every configured group every pass: new mailboxes join all@β¦ automatically, disabled ones drop out, and pbx@β¦-style addresses never get in. If the group address does not exist on Plesk yet, the first push creates it as a forward-only mailname with the computed recipients (not a mailbox). The same definition can be applied once via
mail:group:set <email> --rule='β¦' --recipients=β¦ (both flags combine; with neither, the configured entry for that address is used).
Add --write-config and the applied definition is written into the config file's mail.group (replacing an existing entry for the address), so the watcher keeps maintaining the list without a manual edit.
Auto-replies can be configured the same way via mail.autoresponder entries (address, optional data_file,
start_date, inclusive, subject and format, required end_date; quote the dates so the timezone offset
survives the YAML round-trip).
mail:autoresponder:set <email> without options falls back to the configured entry (a partial CLI run borrows the
missing pieces from it), --write-config persists the applied schedule (and --enabled=false --write-config removes
it), and watch re-records diverging definitions every pass.
--subject sets the reply subject (default: Plesk's Re: <request_subject>); --format plain|html selects the
autoresponder content type (Plesk stores text/plain/text/html; the element order in the XML packet matters,
see docs/NOTES.md). A rendered message that is HTML (e.g. the MJML template) is rejected unless
the format is html β pass --force to store it with a non-html format anyway. --language de|en selects the render language (default: template.language or the first catalog language) β
both the txt and the HTML templates render exactly one language per invocation.
--template renders a specific template for this invocation instead of
template.paths.auto_reply β handy to preview the MJML variant before switching it in the config.
--data-file (or data_file) sources template variables from a JSON or YAML file β e.g. firstname, surname,
phonenumber, department, email. The file is a flat map of Twig variables merged into the render context.
The scheduling window is injected
automatically as startDate/endDate (ISO 8601) and always wins over data-file values, so the rendered text can
never drift from the actual activation window; --inclusive/--no-inclusive (or an inclusive config entry) is
authoritative the same way. Any value that differs per language is a locale map, resolved as key[lang] ?? key.de ?? key
inside the language loop: email: {de: buchhaltung@example.com, en: invoices@example.com} renders the German
address in the German section and the English one in the English section (a plain string applies to every
language, and a locale missing from the map falls back to the German value). The same works for department,
phonenumber and any future field. logo_url optionally overrides
template.urls.logo for this autoresponder (e.g. a seasonal logo). With --write-config the data file path is
persisted alongside the message file, so watch renders the identical message.
Pass --preview to print the rendered message without recording an intent, touching the database or calling Plesk β
a pure dry check before anything goes live. It honors the same config fallback as a regular run, so
mail:autoresponder:set <email> --preview shows exactly what watch would push for a configured entry.
Translations. All sentences live in catalog files under template.paths.translations (default translations,
may point outside the phar): resources/translations/autoresponder.de.yaml, autoresponder.en.yaml, and so on β one file
per language. The templates are pure structure: they loop over the languages found in the catalog directory, so
adding a language is a single new file β no template or config edits. German (de) is the fallback locale: a
missing entry in any catalog degrades to the German text, so raw keys are never shown and rendering never fails β
a stale catalog cannot break the watcher. Each catalog also carries a date_format entry (e.g. d.m.Y vs
F j, Y) so dates render locale-appropriately. The resources/translations/ folder ships the German and English catalogs;
the MJML template (resources/templates/autoresponder.mjml.twig) shares the same sentences and adds phone_button /
email_button labels. The single template picks the body sentence from the catalog: absence when no deputy is
present, and forwarded_to with %deputy% when the absence carries one. Templates that render MJML markup are converted to final HTML automatically by the
rendering pipeline (via the mjml CLI binary β install it with npm install -g mjml or set MJML_BINARY_PATH;
the container image ships it), so the message stored on Plesk is ready to send. Both templates render
one language per invocation (the language context variable β --language, template.language or a
per-entry language; default: the first catalog language). In the HTML template the phone number and email
inside the text are clickable tel:/mailto: links and the action buttons sit side by side below the card.
The email is fully responsive: below 650px viewport width MJML stacks the columns, the 196px page gaps shrink
to 56px and the card tightens its padding. Note that the mjml.io try-it-live pane is desktop-width only β to
preview the mobile layout, shrink your browser window below 650px.
Branding. template.urls.logo sets the logo, template.urls.<network> adds a social icon to the footer row
(map order), and template.organization sets the footer name. Supported networks are facebook, twitter, x,
google, pinterest, linkedin, tumblr and xing (share-capable; the template keeps the configured URL via
-noshare), plus github, instagram, web, snapchat, youtube, vimeo, medium, soundcloud and dribbble.
Omit a network to remove its icon. Template authors should note that Twig consumes one newline after every {% %} tag β keep two blank source lines
where the message should show a blank line (the shipped template documents this in its header comment).
watch.inbox turns plead watch into a drop-folder processor: every *.json file in
watch.inbox.paths.root describes one absence and is applied as an auto-reply (plus an optional
deputy forwarding). ad-account-map.json is reserved and never processed as an entry. The
format is intentionally not tied to a directory service β email addresses a mailbox
directly, samAccountName resolves through the account map or a domain fallback. Schema v1:
{
"schemaVersion": 1,
"action": "schedule",
"email": "erika.mustermann@example.com",
"startDate": "2026-09-17",
"startTime": "17:00",
"endDate": "2026-09-22",
"endTime": "12:00",
"deputyEmail": "vertretung@example.com",
"timezone": "Europe/Berlin",
"language": "de",
"subject": "Automatische Antwort",
"format": "html",
"inclusive": true,
"data": {
"firstname": "Erika",
"surname": "Mustermann",
"department": { "de": "Vertrieb" }
},
"submittedAt": "2026-09-17T16:57:46.987+02:00"
}- Identity β
emailwins; otherwisesamAccountNamethroughad-account-map.json, thendomain(entry orwatch.inbox.domain). - Schedule β
startDate/endDaterequired (YYYY-MM-DD);startTime/endTimeoptional (HH:mm; defaults: start of day / end of day). Times are interpreted intimezone, then the process timezone (Plesk exposes no server timezone β alignTZwith the server). - Two modes β with
deputyEmailthe forwarding template is used (the reply says the mail is forwarded) and a forwarding to the deputy is configured; without it the usual out-of-office template is used and nothing is forwarded. Plesk forwarding keeps a local copy in the mailbox. - Expiry β Plesk disables the reply itself via
end_date, but forwarding has no end date, sowatchremoves the deputy forwarding once the absence has ended (and records it in the audit trail). action: "cancel"β ends an absence immediately (disables the reply, removes the deputy).dataβ arbitrary template variables, merged overwatch.inbox.data_file; per-language maps resolve askey[lang] ?? key.de ?? key.- File lifecycle β valid files move to
processed/; malformed files and unknown mailboxes move tofailed/with a<name>.json.error.txtsidecar.--dry-runleaves files in place. Writers should emit UTF-8 without BOM and write atomically (.tmp+ rename). - Schema β the machine-readable contract is
resources/schema/inbox-entry.schema.json(JSON Schema draft 2020-12, schema v1). - AD generators β see Using the inbox in an AD environment
for a rough GPO β invocation script β server share workflow and how
ad-account-map.jsonresolvessamAccountName.
Environment variables override everything:
PLEAD_SERVERβ selects the server (same as--server; host or index)PLEAD_PLESK_SECRET_KEYβ overrides the selected server's secret keyPLEAD_PLESK_LOGIN/PLEAD_PLESK_PASSWORDβ override the selected server's credentialsPLEAD_DATA_DIRβ overrides the data directory itself (used by the containerized deployment, e.g./data)
Data (SQLite database and log file) lives under the platform data directory β on Linux ~/.local/share/plead/. Every row in the database is scoped to its server (server column), so the desired state and audit trail of multiple servers coexist without collisions. The SQLite database is the authoritative record of the desired state and the full audit trail.
docs/USAGE.mdβ configuration guide and deep dives on the complex commands (groups, auto-replies, the inbox, watch)docs/NOTES.mdβ empirically-verified Plesk XML API facts (packet shapes, quirks, batching) collected against Obsidian 18.0.80docs/AGENTS.mdβ agent/contributor cheat sheet (symlinked to the repo root asAGENTS.md)docs/CONTRIBUTING.mdβ commit format, PR process, repository layoutdocs/ACKNOWLEDGEMENTS.mdβ sources the project learned from
Contributions are welcome via GitHub's Pull Requests. Fork the repository and implement your changes within the forked repository, after that you may submit a Pull Request. Refer to our documentation for contributors for contributing guidelines, commit message formats and versioning tips.
This project is owned and maintained by Ad Noctem Collective refer to
the AUTHORS or CODEOWNERS for more information. You may also use the linked
contact details to reach out directly.
Licensed under the MIT license.