Scheduled Jobs
View and manage scheduled background jobs
Mailyard runs scheduled background jobs for maintenance tasks. All times are UTC.
List Jobs
GET /api/v1/admin/jobs
Response:
{
"jobs": [
{
"name": "retention-cleanup",
"schedule": "0 3 * * *",
"running": false,
"last_run_at": "2026-08-06T03:00:00Z",
"last_error": "",
"next_run_at": "2026-08-07T03:00:00Z",
"last_duration_ms": 412
}
]
}
Run a Job Now
POST /api/v1/admin/jobs/{name}/run
Runs the job immediately, out of band, and answers with the refreshed job list plus what the run did:
{
"jobs": [ ... ],
"ran": "retention-cleanup",
"failed": false
}
A job that ran and failed is still an answered request: 200 with failed: true, and the failure is on that job’s
row as last_error. A name this node does not run answers 404 - which jobs a node runs depends on its role - and a job
already in flight answers 409 rather than being started twice. Both routes require the platform admin role.
Each entry carries name, which is what the run route takes, and schedule, which is display only — see the
scheduling model below.
The rest is state, and all of it belongs to the node that answered the request: running says whether it is in
flight there, last_run_at and last_duration_ms describe that node’s previous run, and next_run_at is when it will
go again. Both timestamps are null until the job has run once since that process started, so a freshly restarted node
reports nulls rather than history.
last_error is empty when the last run succeeded, and holds the failure message otherwise. It is the only place a
failed sweep surfaces — a job that errors is logged and retried on its next tick, and nothing raises an alert about it.
Built-in Jobs
| Job | Schedule | Role | Description |
|---|---|---|---|
retention-cleanup | Daily at 03:00 UTC | worker | Purges expired email logs, received mail, attachment blobs, webhook deliveries, tracking events, audit entries and read notifications according to the retention settings . Also drops spent password reset tokens, expired sessions and the volume counters. |
partition-maintenance | Every hour | worker | Creates the daily partitions of the email log ahead of time. Also run once at boot. |
partition-ceiling | Daily at 03:30 UTC | worker | Mails the administrators when the email log nears its partition ceiling, which only an installation keeping everything forever reaches. |
email-stats | Every 10 minutes | worker | Recomputes the daily delivery trend the dashboard chart reads. |
bounce-alert | Every 15 minutes | worker | Judges the recent bounce rate and raises a notification when it crosses the configured threshold. A rate is a property of a window, so it cannot be evaluated from the delivery path. |
certificate-expiry | Every 6 hours | worker | Mails the administrators about TLS certificates nearing expiry, at most once a day. |
signing-key-expiry | Every 6 hours | worker | Warns a project about sender signing certificates nearing expiry. |
relay-assignments | Every minute | worker | Takes back messages handed to a pull-mode relay node that stopped claiming, so the next candidate delivers them. |
settings-refresh | Every 5 minutes | every | Reloads platform settings from the database so a node that did not serve the write converges. |
Scheduling Model
Schedules come in two shapes: daily at a fixed UTC time, and every N. There is no cron-expression parser - the
schedule field renders cron syntax where it maps purely so the value is familiar to read.
Jobs do not backfill. A node that is down at 03:00 does not run the sweep late, it runs it at 03:00 the next day.
Every node runs every job it registers
There is no leader election. Each node runs its full job set on its own schedule. That is safe for the built-in jobs because each one converges rather than conflicts when two nodes run it at once: the sweeps delete by age, the rollup recomputes in one transaction, the partition job creates what is missing, and the alerts are deduped - but any job added later must be idempotent and tolerate concurrent execution.
Which jobs a node registers depends on its role, as the table says. Every job marked worker is registered by serve
and worker nodes only. settings-refresh is
registered by every role including api, because it is not maintenance: it is how a node’s settings cache learns
about a change written somewhere else. So GET /api/v1/admin/jobs on an api node lists one job, and that is correct.
A panicking job is contained: the failure is recorded as that job’s last_error and the scheduler keeps running.