Platform Integrations

Transactional Email and Notifications

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.


Two layers: render, then dispatch

Transactional email is split into two cooperating pieces.

  • Rendering turns a notification kind plus a context dictionary into a (subject, text_body, html_body) triple. All kinds share one HTML shell, so branding lives in exactly one place.
  • Dispatch takes that triple and sends it over SMTP, reading the mail configuration fresh on each call.

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

The branded shell

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:

  • A dark header with the Versus mark and an amber rule beneath it.
  • A light body carrying the kind's content.
  • A single amber CTA button where an action is required.
  • A dark footer with links to the docs and support.

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.


Notification kinds

A kind is a named builder plus the context it expects. The system ships these transactional kinds:

KindPurposeKey context fields
tenant_inviteInvite a provisioned user to set a password and sign inorg_name, role, set_password_url, expiry_hours (default 24)
tenant_request_receivedConfirm to a requester that their workspace request was receivedorg_name, plan (optional)
tenant_request_approvalTell operators a new workspace request is awaiting a decisionorg_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 and SMTP configuration

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:

  1. Environment variables — the 12-factor path used in hosted deployments. If EMAIL_HOST is set, infrastructure owns the credential and these win outright.
  2. Admin-configured settings — SystemSettings rows an operator can edit from the admin UI, used when no EMAIL_HOST environment variable is present.
Environment variableSystemSettings keyMeaningDefault
EMAIL_HOSTsmtp_hostSMTP server hostname— (unset ⇒ email disabled)
EMAIL_PORTsmtp_portSMTP port587
DEFAULT_FROM_EMAILsmtp_fromFrom header (may include a display name)Versus Networks <no-reply@…>
EMAIL_HOST_USERsmtp_usernameSMTP auth username—
EMAIL_HOST_PASSWORDsmtp_passwordSMTP auth password—

Behavior on send:

  • The connection uses STARTTLS on any port other than 25, and only authenticates when a username is configured — so an unauthenticated relay on port 25 and an authenticated submission service on 587 both work without special-casing.
  • The envelope sender is reduced to the bare address, while the From header may keep a display name (for example Versus Networks <no-reply@…>).
  • If no host is configured, the send is skipped with a logged warning — email is treated as an optional integration, not a hard dependency.

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]>"

Best-effort by design

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.
  • The send_notification(...) wrapper additionally catches render failures — a missing context field or unknown kind — logs them, and returns False.
  • Callers that sit on a critical path wrap the call and swallow anything, so the surrounding transaction always completes.

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.


Who sends transactional email

The shared system is used across several subsystems, each supplying its own kind or subject.

Tenant provisioning

New-workspace flows are the primary users of the branded kinds:

  • A public access request confirms receipt to the requester with tenant_request_received, and can notify operators with tenant_request_approval that a decision is needed.
  • Provisioning invites each user. For a Versus-hosted IdP organization, onboarding is IdP-native — the identity provider sends its own invitation, which runs the user through hosted login (verify email, set a password, enroll MFA), so Versus stays a pure relying party and never handles the credential. Where Versus sends the invite itself, it uses the branded shell with a single-use registration link. See Platform Architecture for how identity fits the stack.

Certificate approvals

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 approvals (Versus Desk)

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.


Event-driven alerting is a separate channel

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 emailAlert notifications
TriggerProvisioning, approvals, lifecycle eventsAlert rule state changes
ChannelsEmail (branded shell)Email, webhook, ServiceNow, Versus Desk
DeliveryBest-effort, one shared mailerPer-contact-point, deduplicated
AuditSender-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.


Operating checklist

  • Turn email on by setting 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.
  • Set a real 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.
  • Use a submission port (587) so STARTTLS and authentication are used; port 25 sends unauthenticated in cleartext-relay mode.
  • Check the audit trail for change approvals — the ApprovalEmailLog shows attempts even when the mailer is off, which is the fastest way to confirm whether an email problem is configuration or delivery.
  • Keep credentials in your secret store, not in plaintext config. In hosted deployments the SMTP password is an infrastructure secret; on-prem, treat the admin-configured value the same way.

Related