How Versus sends branded transactional email — the shared notification shell and its kinds, SMTP delivery that reads its config at send time from the environment or the admin UI, best-effort dispatch that never breaks a workflow, and the tenant, certificate, and change senders that use it.
Versus sends transactional email — tenant invitations, access-request confirmations, certificate-approval requests, change-approval notices — through a single, shared notification system. Every service-side email is rendered from one branded shell and delivered through one mailer, so a tenant invite, a certificate approval, and a change notice are unmistakably Versus regardless of which subsystem produced them.
The design is deliberately small and defensive: rendering is separate from delivery, delivery reads its configuration at send time (so you can change SMTP without restarting the app), and sending is best-effort — a mail outage logs a warning and moves on, it never fails the provisioning or workflow that triggered it.
Transactional email is split into two cooperating pieces.
(subject, text_body, html_body) triple. All kinds share one HTML shell, so branding lives in exactly one place.Because the two are decoupled, swapping the mail provider is a configuration change, never a code change, and the branded shell is guaranteed identical across every email the platform emits.
send_notification(kind, to, ctx)
│
├── render(kind, ctx) ──► (subject, text_body, html_body) # branded shell
│
└── send_versus_email(subject, text, [to], html_body) # SMTP, best-effort
Every transactional email is built from one base template and a small set of blocks. Each kind supplies only its own content — an eyebrow label, a headline, body paragraphs, and at most one call-to-action button. The shell wraps that content identically every time:
Optional building blocks let a kind add a data panel (labeled key/value rows, e.g. Organization, Plan, Status), a colored note callout (amber, red, or green), and a fallback link ("button not working? copy this address") for clients that strip buttons. Every email also carries a hidden preheader — the one-line preview text a mail client shows next to the subject.
Each kind renders both a plain-text and an HTML body. They are attached as a multipart/alternative message so the recipient's client picks whichever it can display, which is also better for deliverability than HTML alone. All interpolated values are HTML-escaped.
A kind is a named builder plus the context it expects. The system ships these transactional kinds:
| Kind | Purpose | Key context fields |
|---|---|---|
tenant_invite | Invite a provisioned user to set a password and sign in | org_name, role, set_password_url, expiry_hours (default 24) |
tenant_request_received | Confirm to a requester that their workspace request was received | org_name, plan (optional) |
tenant_request_approval | Tell operators a new workspace request is awaiting a decision | org_name, requester_email, plan, submitted_at, review_url |
The role field maps friendly labels for the platform roles — tenant_admin → Tenant Admin, operator → Operator, viewer → Viewer — and falls back to a title-cased version of any other value.
Rendering a kind is a pure function:
subject, text_body, html_body = render("tenant_invite", {
"org_name": "Acme Corp",
"role": "tenant_admin",
"set_password_url": "https://…/set-password?token=…",
"expiry_hours": 24,
})
Adding a kind means adding one builder and registering it. Requesting an unknown kind raises a clear error at render time; the dispatch wrapper (below) catches it so a bad kind can never propagate to the caller as an exception.
Delivery goes through a small SMTP helper that talks to a mail server directly. It reads its settings on every send, from two sources in priority order:
EMAIL_HOST is set, infrastructure owns the credential and these win outright.SystemSettings rows an operator can edit from the admin UI, used when no EMAIL_HOST environment variable is present.| Environment variable | SystemSettings key | Meaning | Default |
|---|---|---|---|
EMAIL_HOST | smtp_host | SMTP server hostname | — (unset ⇒ email disabled) |
EMAIL_PORT | smtp_port | SMTP port | 587 |
DEFAULT_FROM_EMAIL | smtp_from | From header (may include a display name) | Versus Networks <no-reply@…> |
EMAIL_HOST_USER | smtp_username | SMTP auth username | — |
EMAIL_HOST_PASSWORD | smtp_password | SMTP auth password | — |
Behavior on send:
From header may keep a display name (for example Versus Networks <no-reply@…>).In the managed cloud, delivery is wired to a transactional email provider over SMTP using a verified sending domain; because the mailer reads configuration at send time, pointing it at a different provider or an internal relay is purely a matter of environment variables. Store the SMTP password like any other secret — see Secret Vault.
# Hosted / 12-factor configuration (values illustrative)
EMAIL_HOST=smtp.your-provider.example
EMAIL_PORT=587
EMAIL_HOST_USER=…
EMAIL_HOST_PASSWORD=… # keep in your secret store, not in plaintext
DEFAULT_FROM_EMAIL="Versus Networks <[email protected]>"
Transactional email must never break the thing that triggered it. A failed invite email should not roll back a tenant that was just provisioned; a mail hiccup should not turn a public access-request form into a 500.
So dispatch is best-effort end to end:
send_versus_email(...) returns True on success and False on any failure (SMTP not configured, connection error, auth failure), logging a warning rather than raising.send_notification(...) wrapper additionally catches render failures — a missing context field or unknown kind — logs them, and returns False.The trade-off is explicit: an email is a notification, not a transaction of record. Where an operator needs proof that a message was attempted, the sending subsystem writes its own audit row (see change approvals, below) rather than relying on the mailer's return value alone.
The shared system is used across several subsystems, each supplying its own kind or subject.
New-workspace flows are the primary users of the branded kinds:
tenant_request_received, and can notify operators with tenant_request_approval that a decision is needed.A certificate workflow can gate issuance behind human approval. The approval step emails everyone holding an admin or cert-approver role, then marks the certificate pending_approval; if no approver addresses resolve, the step logs that fact and continues rather than blocking. See Certificates.
Subject: [Versus] Certificate approval required — <common name>
Body: Workflow run #<id> is waiting on a certificate approval.
Log in to Versus → Certificates to approve or reject.
Change-management notifications — approval requests to the current approval stage, decision notices to the requester, and blackout/maintenance-window notices to stakeholders — go through the same mailer. Critically, every attempted notification writes an audit row (ApprovalEmailLog) regardless of whether SMTP is configured: the "Approval emails" list is an audit projection and records sent, failed, or logged_only for each attempt, so an operator can always see what was tried even on an instance with no mail server.
Transactional email covers workflow- and lifecycle-driven messages. Observability alerts are delivered by a distinct subsystem with different needs, and it is worth not confusing the two.
Alert notifications flow through contact points, which support multiple channel types — email, webhook (Alertmanager-compatible payload), servicenow (open and resolve incidents), and versus_itsm (open a Versus Desk incident) — with per-(alert, contact point, state) deduplication so a scheduler restart can't double-fire, and SSRF egress validation on any outbound URL. Alert email uses its own per-contact-point SMTP settings rather than the transactional mailer.
| Transactional email | Alert notifications | |
|---|---|---|
| Trigger | Provisioning, approvals, lifecycle events | Alert rule state changes |
| Channels | Email (branded shell) | Email, webhook, ServiceNow, Versus Desk |
| Delivery | Best-effort, one shared mailer | Per-contact-point, deduplicated |
| Audit | Sender-specific (e.g. approval logs) | NotificationLog dedup rows |
If you want alerts to raise tickets or hit a webhook, configure a contact point in Observe; if you want to change how invites and approvals look or where they send from, that is the transactional system described here.
EMAIL_HOST (and auth) in the environment, or the smtp_* rows in admin settings. With no host configured, transactional email is silently disabled — every send returns False and logs a warning.From on a domain you've authenticated (SPF/DKIM/DMARC) with your provider, or invites will land in spam. A display-name From like Versus Networks <no-reply@your-domain> is supported.ApprovalEmailLog shows attempts even when the mailer is off, which is the fastest way to confirm whether an email problem is configuration or delivery.