Skip to content

Email (SMTP)

The SMTP driver sends a real email per event — multipart/alternative with both text/plain and text/html parts, so Gmail/Outlook render the rich layout and terminal MUAs see a usable fallback. It works with any SMTP relay: Gmail (with an app password), Amazon SES, Mailgun, Postmark, SendGrid, or your own Postfix on 127.0.0.1.

[[notifier]]
id = "email-ops"
type = "smtp"
host = "smtp.example.com"
port = 587
from = "RunWisp <runwisp@example.com>"
to = ["alerts@example.com"]
username = "apikey"
password = "${RUNWISP_SMTP_PASSWORD}"

id, type, host, from, and at least one to are required. Credentials are optional — leave username and password unset to talk to a local relay (Postfix listening on 127.0.0.1:25, an internal MTA, etc.). Set them together or not at all. Use ${...} substitution to pull the password from an env var or a file — storing the secret.

KeyRequiredWhat it does
hostyesSMTP relay hostname (e.g. smtp.gmail.com).
portnoTCP port. Defaults to 25; common values: 587 (STARTTLS), 465 (implicit TLS).
tlsno"starttls" (default for 25/587), "implicit" (default for 465), "none".
tls_skip_verifynoSkip TLS certificate verification. Do not set this in production.
usernamenoSMTP AUTH username. Required when sending creds.
passwordnoSMTP AUTH password — inline, ${VAR}, or ${file:path}.
fromyesFrom: header. Accepts "name@example.com" or "Name <name@example.com>".
reply_tonoOptional Reply-To: header.
toyesArray of To: recipients (at least one).
ccnoArray of Cc: recipients.
bccnoArray of Bcc: recipients.
template_pathnoOverride the embedded HTML message template.

Gmail accepts SMTP on smtp.gmail.com:587 via STARTTLS. Use an app password — Gmail rejects regular account passwords for SMTP.

[[notifier]]
id = "email-ops"
type = "smtp"
host = "smtp.gmail.com"
port = 587
from = "Your Name <you@gmail.com>"
to = ["alerts@example.com"]
username = "you@gmail.com"
password = "${RUNWISP_GMAIL_APP_PASSWORD}"
Terminal window
export RUNWISP_GMAIL_APP_PASSWORD='xxxx xxxx xxxx xxxx'

Pick where the password lives and reference it with ${...} substitution.

The simplest option for any deployment — Docker, systemd, bare metal. Set the variable in whatever already manages your environment.

Terminal window
export RUNWISP_SMTP_PASSWORD='your-app-password'
runwisp daemon
[[notifier]]
id = "email-ops"
type = "smtp"
host = "smtp.example.com"
from = "runwisp@example.com"
to = ["ops@example.com"]
username = "apikey"
password = "${RUNWISP_SMTP_PASSWORD}"

Identical to Slack and Telegram — the routing layer does not care which driver is on the other end:

# On one task:
[tasks.backup-postgres]
notify_on_failure = ["email-ops"]
# …
# Or in a notification rule:
[[notification_route]]
match = { kind = ["run.failed", "run.timeout", "run.crashed"] }
notify = ["email-ops"]

Inline recipient overrides reuse one credential set across many destinations — notify_on_failure = ["email-ops:db-team@example.com"] sends through the email-ops relay but to db-team@example.com instead of the parent notifier’s to list:

[tasks.backup-postgres]
notify_on_failure = ["email-ops:db-team@example.com"]

You can fan out to multiple channels in the same array — the router sends to all of them, and an outage of one does not stop delivery on the others:

[tasks.critical-job]
notify_on_failure = ["email-ops", "slack-ops", "tg-oncall"]
Terminal window
runwisp exec smoke-test # something that exits non-zero

Emails typically arrive in a few seconds. If yours doesn’t:

  • Check the bell for a notify.delivery_failed event with the underlying SMTP error.
  • Common causes: wrong port for the chosen tls mode (Gmail needs 587 + STARTTLS; SES on 465 needs tls = "implicit"), expired app password, recipient address rejected by the relay (550 No such user).
  • Spam folder: the first message from a new sender often lands in spam. Whitelist the from address.

The default template renders one message per event. A failure looks like:

❌ backup-postgres failed

Exited with code 1 after 0.3s. Scheduled run · 14 May, 17:11.

Error: connection refused
dial tcp 127.0.0.1:5432: connect:
connection refused

[View full run]

from runwisp · bright-falcon

  • The captured-output block appears on run.failed and run.timeout only, capped at three lines / 300 bytes.
  • The View run link points at <external_url>/tasks/<task>/<id>, taken from [daemon] external_url. When external_url is unset the link is omitted — no broken anchors.
  • The fingerprint footer identifies the specific daemon that emitted the event.
  • The plain-text alternative is derived from the HTML body, so terminal MUAs (mutt, mail) see the same content without markup.

template_path overrides the embedded template if you need a different layout. Copy smtp.tmpl.html as your starting point. The helpers statusEmoji, statusVerb, humanTime, humanDuration, runDuration, triggerPhrase, eventSentence, eventTrigger, linkLabel, runURL, taskURL, outputTail, htmlEsc, and fingerprint are available inside it.

  • username set without password — or password set without username. Either both or neither.
  • tls = "none" combined with credentials — RunWisp refuses to send PLAIN auth over cleartext.
  • A from, reply_to, to, cc, or bcc value that doesn’t parse as an RFC 5322 address.
  • An id containing : (reserved for inline target overrides) or equal to "inapp" (reserved).