Email (local MTA)
If the machine already sends mail, RunWisp can just use that. The
sendmail driver pipes a plain-text message into the host’s
sendmail-compatible binary — Postfix, exim, msmtp, ssmtp — exactly
the way crond has always delivered MAILTO output.
That’s the whole appeal: no host, no port, no TLS mode, no password.
The MTA already knows where to relay and already holds the
credentials, and queueing and retrying stay its job. If you’re
migrating a crontab that had [email protected] at the top,
this is the closest thing to what you had.
Fields
Section titled “Fields”[[notifier]]id = "mta"type = "sendmail"That’s a complete notifier. id, type, from, and at least one
to are required; everything else is optional.
| Key | Required | What it does |
|---|---|---|
from |
yes | From: header. Accepts "[email protected]" or "Name <[email protected]>". |
to |
yes | Array of To: recipients (at least one). |
cc |
no | Array of Cc: recipients. |
bcc |
no | Array of Bcc: recipients. Stripped by the MTA before delivery. |
reply_to |
no | Optional Reply-To: header. |
sendmail_path |
no | Absolute path to a specific binary. Unset means find the system one. |
template_path |
no | Override the embedded plain-text message template. |
Finding the binary
Section titled “Finding the binary”With sendmail_path unset, RunWisp tries /usr/sbin/sendmail,
/usr/lib/sendmail, /usr/bin/sendmail, and then sendmail on
$PATH — in that order. Every common MTA installs one of those.
Set sendmail_path when you want a specific mailer rather than
whichever one happens to own the compatibility symlink:
[[notifier]]id = "mta"type = "sendmail"sendmail_path = "/usr/local/bin/msmtp"It must be an absolute path. A relative one is rejected at load —
the daemon can run from anywhere, and resolving a mailer through
$PATH at send time is how you end up executing a different binary
than the one you configured.
The binary is looked up on the first send, not at startup. A
config that validates on your laptop shouldn’t refuse to load on a
server where the MTA package hasn’t finished configuring yet. If
nothing is found, the delivery fails with an error naming both the
paths it tried and sendmail_path.
Route failures to it
Section titled “Route failures to it”Nothing special here — the routing layer doesn’t care which driver is on the other end:
[tasks.backup-postgres]cron = "30 2 * * *"run = "/usr/local/bin/backup.sh"notify_on_failure = ["mta"]Or once, for everything:
[[notification_route]]match = { kinds = ["run.failed", "run.timeout", "run.crashed"] }notify = ["mta"]Inline overrides work too, which is the closest equivalent to a
per-crontab MAILTO — one notifier, a different recipient per task:
[tasks.nightly-report]What a message looks like
Section titled “What a message looks like”Plain text, no markup — this goes down a pipe and gets read in
mutt as often as in Gmail:
From: [email protected]Subject: backup-postgres failedDate: Thu, 14 May 2026 17:11:04 +0200MIME-Version: 1.0Content-Type: text/plain; charset=utf-8Auto-Submitted: auto-generatedX-Auto-Response-Suppress: AllUser-Agent: runwisp-notify/1
backup-postgres failed
Exited with code 1 after 0.3s.
Scheduled run · 14 May, 17:11.
Error: connection refuseddial tcp 127.0.0.1:5432: connect: connection refused
View run: https://runwisp.example.com/tasks/backup-postgres/01J...
--from runwisp · bright-falcon- The captured-output block appears on
run.failedandrun.timeoutonly, capped at three lines / 300 bytes. - The View run link comes from
[daemon] external_urland is omitted when that’s unset. Auto-SubmittedandX-Auto-Response-Suppresskeep a failing task from striking up a correspondence with an out-of-office bot.- Non-ASCII subjects are RFC 2047 encoded, so accents survive.
template_path overrides the embedded template. Copy
sendmail.tmpl.txt
as your starting point; the helpers statusVerb, humanTime,
humanDuration, runDuration, triggerPhrase, eventSentence,
eventTrigger, linkLabel, runURL, taskURL, outputTail, and
fingerprint are available inside it.
How it invokes the mailer
Section titled “How it invokes the mailer”RunWisp runs <binary> -t -i and writes the message to its stdin.
-t means the MTA reads the recipients out of the headers, so no
address is ever passed as a command-line argument — an address
can’t turn into a flag, and Bcc gets stripped for you. -i stops
a body line consisting of a single . from being read as
end-of-input, which would otherwise silently truncate a job’s
captured output.
Any header value containing a newline is rejected before the message is composed, so a task name or a rendered subject can’t inject headers.
Retries
Section titled “Retries”Only exit code 75 (EX_TEMPFAIL from sysexits.h) is retried —
that’s the one status every sendmail-compatible binary uses for “I
couldn’t reach the relay / couldn’t write the queue, ask again”.
Anything else (a rejected address, a permissions problem, a broken
MTA config) fails the same way next time, so RunWisp gives up
immediately and emits a notify.delivery_failed event with the
MTA’s own stderr attached. Check the bell.
Coming from MAILTO
Section titled “Coming from MAILTO”runwisp import cron spots MAILTO= in a crontab and hands you the
notifier block to paste. One difference is worth internalising
before you rely on it:
crond mailed any output at all. RunWisp mails an event. A job
that printed a line and exited 0 generated cron mail; here it
doesn’t, unless you ask for it with notify_on_success. What you
get by default is failures — which is usually what people actually
wanted from cron mail, but it is not the same rule.
See How cron maps to TOML for the rest of the mapping.
What the loader rejects
Section titled “What the loader rejects”- A missing
from, or an emptyto. - A
from,reply_to,to,cc, orbccvalue that doesn’t parse as an RFC 5322 address. - A relative
sendmail_path. - An
idcontaining:(reserved for inline target overrides) or equal to"inapp"(reserved).