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.

[notifiers.email-ops]
type = "smtp"
host = "smtp.example.com"
port = 587
from = "RunWisp <[email protected]>"
username = "apikey"
password = "${RUNWISP_SMTP_PASSWORD}"

The id (as the table key), 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.

Key Required What it does
host yes SMTP relay hostname (e.g. smtp.gmail.com).
port no TCP port. Defaults to 25; common values: 587 (STARTTLS), 465 (implicit TLS).
tls_mode no "starttls" (default for 25/587), "implicit" (default for 465), "off".
tls_skip_verify no Skip TLS certificate verification. Do not set this in production.
username no SMTP AUTH username. Required when sending creds.
password no SMTP AUTH password — inline, ${VAR}, or ${file:path}.
from yes From: header. Accepts "[email protected]" or "Name <[email protected]>".
reply_to no Optional Reply-To: header.
to yes Array of To: recipients (at least one).
cc no Array of Cc: recipients.
bcc no Array of Bcc: recipients.
template_path no Override 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.

[notifiers.email-ops]
type = "smtp"
host = "smtp.gmail.com"
port = 587
from = "Your Name <[email protected]>"
username = "[email protected]"
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
[notifiers.email-ops]
type = "smtp"
host = "smtp.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:
[[route]]
match = { kinds = ["run.failed", "run.timeout", "run.crashed"] }
notifiers = ["email-ops"]

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

[tasks.backup-postgres]
notify_on_failure = ["email-ops:[email protected]"]

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 run 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_mode = "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_mode = "off" 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.
  • A notifier id containing : (reserved for inline target overrides) or equal to "inapp" (reserved).