Overview
The WorkspaceSmtpConfig lets a workspace bring its own email transport — host, port, credentials, encryption mode — overriding the platform-wide EMAIL_HOST defaults. Outgoing emails from the workspace (invitations, scheduled reports, password resets, alert digests, public-form acknowledgements) then route through the workspace's SMTP provider, with the workspace's own From address and branding, instead of the shared platform sender.
The configuration lives at /ws/{slug}/settings/email and was introduced in c81fa3d. It's optional — workspaces without a config keep using the platform fallback.
Why it exists
Operations and security teams generally don't want password-reset and invitation emails routing through a vendor's shared SMTP pool — those mails should land in the recipient's inbox under the customer's own deliverability reputation, with the customer's SPF/DKIM/DMARC, and from a customer-controlled address. The WorkspaceSmtpConfig lets each tenant cut over to their own transactional ESP (SendGrid, Postmark, Mailgun, in-house Postfix) without operator involvement, while keeping the platform fallback as a sensible default for tenants who don't care.
Key concepts
WorkspaceSmtpConfig model
WorkspaceSmtpConfig (one-to-one with Workspace) holds:
- host — SMTP server hostname (e.g.
smtp.sendgrid.net). - port — typically 587 (STARTTLS) or 465 (implicit TLS / SSL).
- username — SMTP username (e.g.
apikeyfor SendGrid). - encrypted_password — encrypted via CMEK descriptor; never returned over the API in plaintext.
- use_tls — bool; true for STARTTLS upgrade on port 587.
- use_ssl — bool; true for implicit SSL on port 465.
- default_from_email — header sender (e.g.
notifications@example.com). - default_from_name — display name (e.g.
Acme Operations). - reply_to — optional
Reply-Toheader; useful when From is a no-reply. - is_active — toggle without losing the credentials.
- last_test_at, last_test_result — denormalised health from the test-send button.
Encrypted password (CMEK)
The SMTP password is stored encrypted using the same CMEK descriptor that protects integration secrets. The encrypted blob carries a descriptor pointer; on read, the descriptor is resolved and the password is decrypted only inside the Django app process that has access to the descriptor key. The plaintext is never written to the DB, never logged, and never returned via the API (the GET endpoint redacts to "********").
Because decryption requires the CMEK descriptor resolution path (which lives in the Django app), the FastAPI alert dispatcher cannot decrypt workspace-SMTP passwords today — see the caveat below.
Test-send button
The configuration page has a Send test email button. It uses the in-form values (without saving) plus a recipient address provided by the admin. The test:
- Opens an SMTP connection with the supplied host/port/encryption.
- Authenticates with username + password.
- Sends a small "Test from Vigilo" email to the recipient.
- On success, updates
last_test_atandlast_test_result='ok'. - On failure, surfaces the SMTP server's error message inline (no save).
This catches typos before saving and avoids the "I'll find out the credentials are wrong when the next invite fails" gap.
TLS vs SSL semantics
The two common encryption modes are easy to confuse:
- Port 587 with STARTTLS (
use_tls=true,use_ssl=false) — the connection opens in plaintext and is upgraded to TLS via the STARTTLS command. This is the IETF-recommended default. - Port 465 with implicit SSL (
use_tls=false,use_ssl=true) — the connection is TLS from byte zero, no STARTTLS dance. Older but widely supported.
You almost certainly want one or the other but not both true. The UI surfaces a warning if both are enabled.
Some legacy SMTP servers offer plaintext port 25 — Vigilo technically supports it (set both flags false) but the configuration UI shows a red banner because no transactional ESP in the modern era should be accepting unencrypted SMTP.
Reply-To handling
The reply_to header is independent of from_email. Typical pattern:
from_email = noreply@example.com— for SPF/DKIM alignment.reply_to = support@example.com— so recipients who hit reply land in the support inbox.
Both headers are inserted on every outgoing message dispatched through this config.
Fallback to env
When no WorkspaceSmtpConfig exists for a workspace, or is_active=False, the Django email backend falls back to the platform-wide env:
EMAIL_HOSTEMAIL_PORTEMAIL_HOST_USEREMAIL_HOST_PASSWORDEMAIL_USE_TLS/EMAIL_USE_SSLDEFAULT_FROM_EMAIL
The fallback is silent — the platform fallback is the default behaviour, not an error.
FastAPI dispatcher caveat
A subset of email dispatches happens from the FastAPI service, not Django — specifically, the certificate-monitoring alert dispatcher, because the monitoring loop and the alert pipeline live in the FastAPI tier.
Because FastAPI does not load the Django app context, it cannot decrypt CMEK-protected passwords. The FastAPI dispatcher therefore:
- Looks up
WorkspaceSmtpConfigdirectly via SQL (no CMEK access). - Uses
host,port,username,default_from_email,default_from_name, encryption flags. - Falls back to the env
EMAIL_HOST_PASSWORDfor authentication, because it cannot resolve the encrypted blob.
Practical implication: alert emails will work with the platform SMTP password but route through the workspace's chosen host. If the workspace uses a different ESP password from the platform, FastAPI alert emails will fail to authenticate. The roadmap fix is either a side-channel CMEK service or splitting workspace-SMTP passwords into a separate non-CMEK store accessible to both tiers. Until then, document this for tenants who care.
Common workflows
Configure a workspace SMTP (SendGrid example)
- Settings → Email, click Configure SMTP.
- Host
smtp.sendgrid.net, port587, usernameapikey, password your SendGrid API key. use_tls = true,use_ssl = false.- From
notifications@yourdomain.com, From nameAcme Ops. - Send test email to your own inbox. Wait for delivery, confirm SPF/DKIM align.
- Save. From the next outgoing message, the workspace uses your SMTP.
Configure with implicit SSL (Postmark on 465)
- Host
smtp.postmarkapp.com, port465, username your server token. use_tls = false,use_ssl = true.- Send test. Save.
Rotate the password
- Open the SMTP config, click Update password.
- Paste the new secret. Click Save; the old encrypted blob is replaced.
- Send a fresh test to confirm the new credential is valid.
Disable temporarily
Toggle is_active = false. The platform env fallback resumes immediately for Django-dispatched emails. FastAPI-dispatched alerts are unaffected (they already use the env password).
Permissions
- Owners and admins can configure, test, and rotate SMTP.
- Engineers can read the redacted config (no password) and trigger a test.
- Viewers / approvers see only that SMTP is configured, no details.
All edits and tests are audited; failed tests also log the SMTP server error message for debugging.
Troubleshooting
Test email times out — Likely a firewall blocking outbound on the chosen port. Confirm 587 (or 465) is open from the Django host's egress. Some cloud providers block 25 by default and require an account flag for 587.
Test returns 535 Authentication failed — Username or password is wrong. For SendGrid the username is the literal string apikey; for many providers it's an email address.
Test succeeds but real emails don't deliver — SPF/DKIM/DMARC misalignment. The From address must be authorised by the configured DNS records for the chosen ESP. Check the recipient's spam folder and the ESP's delivery log.
Both use_tls and use_ssl true — Pick one. The Python SMTP library will likely error or hang. The config UI warns you.
FastAPI alert email fails auth, Django email works — The FastAPI dispatcher caveat above. Either align the workspace SMTP password with the env EMAIL_HOST_PASSWORD, or accept that alert emails route through the platform fallback until the CMEK side-channel is built.
Reply-To showing as From in some clients — Some clients (notably older Outlook) display Reply-To prominently. The header is correctly set; this is a client rendering quirk. Use a distinct From display name to disambiguate.