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 <[email protected]>"
username = "apikey"
password_env = "RUNWISP_SMTP_PASSWORD"

id, type, host, from, and at least one to are required. Credentials are optional — leave username and the password fields unset to talk to a local relay (Postfix listening on 127.0.0.1:25, an internal MTA, etc.). When username is set, exactly one of password, password_env, password_file must also be set — the secret rule.

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.
passwordone-of (see below)Inline password. Avoid in committed configs.
password_envone-ofName of an env var holding the password.
password_fileone-ofPath to a file containing the password. Relative paths resolve under the data dir.
fromyesFrom: header. Accepts "[email protected]" or "Name <[email protected]>".
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 <[email protected]>"
username = "[email protected]"
password_env = "RUNWISP_GMAIL_APP_PASSWORD"
Terminal window
export RUNWISP_GMAIL_APP_PASSWORD='xxxx xxxx xxxx xxxx'

Pick one of the three options below. Setting more than one is a config-load error.

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"
username = "apikey"
password_env = "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:[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 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>?runId=<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.

  • Two of password, password_env, password_file set at the same time.
  • username set without any password source — or a password source 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).