Skip to content

Outbound Email (SMTP)

TruePPM sends outbound email — @mention notifications and the own-task notifications (a task assigned to you, the planned date of your task changing, a comment on your task) — through Django’s email backend. Delivery is best-effort and opt-in: a notification is emailed only when the recipient has turned the Email channel on for that event under User → Settings → Notifications. Email is off by default.

You configure the outbound transport one of two ways: in-app from the Workspace → Settings → Email & SMTP page, or through Django’s standard EMAIL_* environment settings. The in-app page is the primary surface; the EMAIL_* settings remain the fallback the page uses in its default TruePPM cloud mode.

The Workspace → Settings → Email & SMTP page lets the install operator configure the outbound transport without editing settings or redeploying. Once a transport is configured here, it governs all outbound mail — notifications, invites, and password-reset messages alike — overriding the EMAIL_* environment defaults. Left on the built-in Server default transport, behavior is unchanged: mail flows through whatever the EMAIL_* settings configure, exactly as it did before this page existed.

Any workspace admin can view the current email posture on this page. Only the install operator — a Django superuser — can change it. The transport is installation-global, so a single-project admin cannot repoint every outbound message at an attacker-controlled relay: the write path is operator-only by design, while the read path stays open to admins who need to see how mail is set up.

You pick the outbound service from a single Provider dropdown, and the rest of the form appears based on your choice. Gmail, Microsoft 365 / Outlook, and Fastmail are first-class presets: selecting one pre-fills the host, port, and connection security for you (all still editable behind an Advanced — server settings reveal), so you never have to look up smtp.gmail.com or which port Fastmail wants.

ProviderWhat it does
Server default (built-in)The default. Falls back to the EMAIL_* environment settings your operator configured at deploy time. No SMTP credentials are stored on the row.
GmailPre-fills smtp.gmail.com · 587 · STARTTLS. Requires a Google App Password — see Gmail App Passwords below.
Microsoft 365 / OutlookPre-fills smtp.office365.com · 587 · STARTTLS.
FastmailPre-fills smtp.fastmail.com · 465 · SSL/TLS. Use a Fastmail app password.
SendGridSends through SendGrid’s SMTP relay. You supply only the API key (the host and username are fixed).
Amazon SESSends through the region’s SES SMTP relay. You supply the region-derived host, username, and password.
Custom (generic) SMTPYou supply the host, port, connection security (None / STARTTLS / SSL-TLS), and — unless the relay accepts unauthenticated connections — a username and password.

The presets, SendGrid, SES, and Custom SMTP all build a standard SMTP connection — a preset is just a Custom SMTP configuration with the host, port, and security filled in for you. All of them are transport_mode='smtp' on the server except SendGrid and SES, which have their own relay modes.

Every provider except Server default and SendGrid also needs a username — see SMTP username for what each one expects and where to find it.

Signing in to a mail server is always a pair: the username says which account, the password proves it. An app password replaces your account password — never your username — so a username is required whenever a password is, app password or not.

TruePPM rejects a save with a blank username paired with a password on the Custom SMTP, preset, and Amazon SES transports. This is deliberate: an SMTP connection with no username does not authenticate at all, so a lone password would connect successfully and then fail every real send with 530 Authentication Required — the save would look fine and mail would silently stop.

ProviderWhat to enterWhere to get it
GmailThe full address of the Google account that owns the App Password (you@gmail.com). On Google Workspace use the account’s primary address even when sending as an alias — put the alias in From address instead.The Google account itself
Microsoft 365 / OutlookThe mailbox’s full sign-in address (UPN), not a display name or alias.The M365 account. Authenticated SMTP must also be enabled for that mailbox — it is off by default on new tenants (admin center → Users → Active users → the mailbox → Mail → Manage email apps).
FastmailYour full Fastmail address, including the domain.Fastmail Settings → Password & Security → App Passwords. Give the app password the Mail (SMTP) scope — one scoped to another service authenticates and then refuses to send.
Amazon SESThe SES SMTP username — the access key beginning AKIA that SES issues in the SMTP-credentials flow.SES console → SMTP settings → Create SMTP credentials. The matching SMTP password is derived from the secret key and shown once; an ordinary IAM access key and secret will not work, because the secret has to go through that derivation.
SendGridNothing — the field is not shown. The username is fixed server-side to the literal apikey and the API key travels as the password.n/a
Custom (generic) SMTPWhatever account your relay signs in as — many expect the full email address, others a bare login name.Your relay’s SMTP documentation

Custom SMTP is the one transport that may be saved with both Username and Password left blank — for a relay on a trusted network that accepts unauthenticated connections, such as Postfix’s mynetworks, an in-cluster relay, or any server behind a network boundary you already control. This pairs with Security: None below, which the docs have always named for exactly this use case. Leave both fields blank and TruePPM will not attempt SMTP AUTH at all — Django’s SMTP backend skips the login step whenever username and password are both empty, so there is nothing to fail.

A blank username with a non-blank password is not the same thing and is still rejected: the password would never be presented to the server (no username to pair it with), so it is a dead credential rather than “no auth.” Username and password are a pair — both set, or both blank.

Switching an already-credentialed Custom SMTP config to no authentication (clearing the username) also clears the stored password, even if you don’t touch the password field — a no-auth relay needs no password, and TruePPM does not keep a secret around that a re-added username could silently start using again later.

SendGrid and Amazon SES have no unauthenticated mode and always require a credential — the no-auth carve-out is Custom SMTP only.

A username that does not match the account the password belongs to fails with 535 Username and Password not accepted on save, because the transport is probed before it is persisted (see Validation before save).

Gmail no longer accepts your normal Google account password over SMTP — “less secure app access” was retired. A 16-character App Password is the only way to authenticate, and it requires 2-Step Verification. The Gmail preset shows this same walkthrough inline (the ⓘ next to the App password field):

  1. Turn on 2-Step Verification for the Google account you’ll send from.
  2. Create an App Password at myaccount.google.com/apppasswords. Google shows a 16-character string once.
  3. Paste that 16-character App Password into the App password field on the Email & SMTP page — not your account password.
  4. Leave Security on STARTTLS (587), the Gmail preset default.

Gmail’s OAuth2/XOAUTH2 sign-in flow is intentionally not offered: App Passwords are the pragmatic, self-hoster-friendly path and need no registered Google OAuth app.

The Security dropdown controls how TruePPM secures the SMTP connection. The Email & SMTP page carries the same guidance inline (the ⓘ next to the field):

SecurityPortWhat it does
STARTTLS587Connects in the clear, then upgrades to TLS before login. Recommended — the right choice for almost every provider.
SSL/TLS465TLS from the first byte (implicit). Use it when your provider only offers 465 (e.g. Fastmail).
None25Plaintext, no encryption. Credentials and mail travel in the clear — only for a trusted internal relay on a private network, never over the public internet. Selecting it shows an explicit warning. By default the SSRF guard described below rejects a private-network host outright, so pointing this at an internal relay also requires the TRUEPPM_EGRESS_ALLOWLISTED_HOSTS allowlist — see SSRF egress guard. A relay reachable only this way often has no SMTP AUTH either — see No authentication above.

The password is encrypted and never returned

Section titled “The password is encrypted and never returned”

The SMTP password (or SendGrid API key) is encrypted at rest with Fernet, using the INTEGRATION_ENCRYPTION_KEY, and is never returned by the API — the page shows only whether a password is set (password_is_set), never the value. Leaving the password field blank on save keeps the stored secret, so you can edit other fields without re-entering it. Switching to a different transport does require re-entering the password (a SendGrid API key is not an SES password) — except when switching a Custom SMTP config to no authentication, which never carries a password forward and instead clears whatever was stored.

A save opens the candidate transport before it is persisted. If the host, port, security, or credentials are wrong, the save is rejected with a 400 and nothing is written — a bad configuration can never lock the workspace out of mail. A real connect failure gets a deliberately generic error and never echoes the underlying SMTP exception (which could leak credentials). A host rejected by the SSRF egress guard is the one case that gets a more specific message instead, since that message is curated to never leak the resolved address and telling you to recheck credentials would be wrong — see that section for what to do next.

Alongside the transport, the page configures:

  • From identity — a From name, From address, reply-to address, and DKIM selector for the outbound From: header.

  • Delivery limits — Max queued emails per batch caps how many queued emails one pass of the delivery queue sends, and Throttle per minute caps the rate across passes (0 means no throttle). The queue runs every 30 seconds, so a per-minute throttle is applied as half of it per pass, and the tighter of the two limits wins. A throttle that divides below one still sends one message per pass rather than stalling the queue outright.

    The batch cap has a ceiling of 50, because one pass runs under a time limit. A larger value is rejected with a 400 rather than accepted and then quietly reduced. 0 means “no explicit cap” and falls back to the same 50; clearing the field posts 0, which is why it is accepted.

    The throttle is a shared per-minute allowance across every outbound path — notification email, workspace invites, password resets, and export-ready notices all draw from it, so no single path can spend the whole allowance. Password resets and export-ready notices are transactional one-offs and are always sent even once the allowance is spent; they count against it, so the queues yield to them rather than stacking on top.

The SMTP host is SSRF-guarded: a host that resolves to a private, loopback, link-local, or cloud-metadata address is rejected, and it is re-checked at send time to close the DNS-rebinding window. This is the case the None security row above calls out — a trusted internal relay on a private network is exactly what the guard blocks by default, so a save against one fails with a specific error naming the host as not permitted, distinct from a generic connect failure (it does not tell you to re-check credentials that are already fine).

To point TruePPM at an internal relay, add its hostname to the TRUEPPM_EGRESS_ALLOWLISTED_HOSTS environment variable (a comma-separated list, matched as an exact, case-insensitive hostname — no wildcard or suffix match, so allow-listing mail-relay does not admit mail-relay.attacker.example). Set it on the API, Celery worker, and Celery beat processes — the API process validates the host at save time, and the worker and beat processes re-run the same check at send time, so a mismatch between them fails a save on one process and every actual send on another. After setting it, save the Email & SMTP settings again to re-run validation against the now-allowed host.

The allowlist is a single global list, not scoped to the mail transport — allow-listing a relay for email also opens it to every other egress surface that shares this chokepoint (webhooks, SSO, PAT verification, git-link refresh). Until per-surface scoping ships (tracked in #3561), treat adding a host here as trusting it for outbound requests generally, not just for SMTP.

The page has a Send test email action that sends a fixed test message through the resolved transport. It always sends to the requesting operator’s own account address — never an address from the request — so the action can never be used as an authenticated open relay. You get an immediate pass/fail result: a real transport failure returns a generic 502, and a host rejected by the SSRF egress guard returns 502 with the same specific, allowlist-pointing message the save path gives.

The page runs live SPF / DKIM / DMARC checks against the From-address domain: bounded DNS TXT lookups that report each record as pass, warn, or fail. The lookups run only against the persisted, validated From domain (never a domain from request input), are operator-gated, and degrade to “checks unavailable” rather than erroring when no DNS resolver is present. Use this to confirm your DNS is aligned before mail starts landing in spam — see SPF, DKIM, and DMARC alignment for what each record means.

The write, send-test, and health endpoints are tightly rate-limited — each write re-opens a candidate SMTP connection and each health check is an outbound DNS egress, so both are throttled to keep the surface from being abused.

In the built-in Server default transport mode — and on any install before the in-app page ships — TruePPM uses Django’s standard EMAIL_* settings. Mail is sent by a periodic Celery Beat job (TruePPM’s background job scheduler; see Beat Liveness) rather than inline, so it runs on the api, celery, and celery-beat workloads alike — all three need the same transport configuration.

SettingPurpose
EMAIL_HOSTSMTP relay hostname. Unconfigured ⇒ “Not configured” on the Email & SMTP page.
EMAIL_PORTSMTP port (e.g. 587).
EMAIL_USE_TLSUse STARTTLS.
EMAIL_USE_SSLUse implicit SSL/TLS (mutually exclusive with TLS).
EMAIL_HOST_USERSMTP username. Never exposed by the API.
EMAIL_HOST_PASSWORDSMTP password. Never exposed by the API, never logged.
EMAIL_TIMEOUTSocket timeout in seconds for the SMTP connection (default 10). Raise it if your relay is slow to respond; a hung connection otherwise blocks the Beat-driven notification drain until it times out.
DEFAULT_FROM_EMAILFrom address on every message (e.g. notify@example.com).
EMAIL_BACKENDDjango backend; use the SMTP backend in production.

Source EMAIL_HOST_PASSWORD from a secret manager that your settings override reads — never commit it in plain text.

DEFAULT_FROM_EMAIL is the domain receiving mail servers check SPF, DKIM, and DMARC alignment against. TruePPM sends the mail; your DNS configuration is what makes it trusted:

  • Use a DEFAULT_FROM_EMAIL domain you control and have published SPF and DKIM DNS records for. Most self-hosters send through a relay (Amazon SES, SendGrid, Postmark, or their own mail server via EMAIL_HOST) — the relay’s setup docs walk through adding the required SPF (TXT) and DKIM (CNAME/TXT) records at your registrar or DNS host.
  • DMARC alignment requires the From domain (DEFAULT_FROM_EMAIL) to match either the SPF-authenticated domain or the DKIM-signing domain. If your relay signs with a different domain than DEFAULT_FROM_EMAIL, alignment fails even though the message was accepted and delivered by the relay.
  • A relay reporting “sent successfully” only confirms SMTP accepted the message — it says nothing about SPF/DKIM/DMARC alignment at the receiving end. Misaligned records are the most common reason self-hosted notification email lands in spam even though delivery looked fine from the sending side.

SPF, DKIM, and DMARC are DNS records you own and publish, not something a .env value or Helm value configures — TruePPM cannot enforce alignment for you. It can, however, check it: the Deliverability health panel on the Email & SMTP page runs live SPF/DKIM/DMARC lookups against your From domain and flags each as pass, warn, or fail, so you can confirm the records are in place before mail goes out.

  • Email is queued as a notification row and sent by the drain_notification_emails background task, never inline — a broker or SMTP outage delays delivery but does not block the triggering action.

  • A new email normally leaves within a few seconds. When the action that queues it (an @mention, a task assignment, a workspace invite) commits, TruePPM starts the delivery task straight away; the 30-second Beat run catches anything that start missed, so the worst case on a healthy install is about 30 seconds plus your relay’s own latency. The per-minute throttle below can hold a burst longer. Workspace invites are sent the same way by drain_invite_emails. Releases up to and including 0.4.0-beta.4 held every first send for at least 5 minutes (a resend went out at once), so on those versions an invite or @mention email arriving about 5 minutes late is expected behavior, not a broken relay.

  • Each message is retried up to 3 times; after that the notification remains in the in-app inbox but stops attempting email. On a 30-second cadence that is roughly 90 seconds from first attempt to permanent failure — see Knowing when mail stops working for how that permanent failure is surfaced.

  • If the workspace has an SMTP transport configured whose stored credential cannot be decrypted, sends fail closed: nothing is sent over a different transport, no retries are burned, and the queued rows are left untouched so they deliver once you re-enter the password. The System Health card and the trueppm_email_transport_unavailable metric both name this cause directly.

  • Bodies are plain text. A recipient with no email address is skipped (the in-app notification still appears).

  • Bodies carry a direct deep-link to the affected task, workspace danger zone, or notification-preferences page when FRONTEND_BASE_URL is set (e.g. the task.blocked email links straight to the blocked task). Leave it empty and every one of those emails still renders — it just omits the link line and substitutes plain prose (e.g. “Open the task in TruePPM” instead of a URL). No outbound email ever contains a bare relative link (/settings/..., /invite/accept?...): a link with no scheme or host is not clickable in a mail client, so it is never emitted at all.

    Workspace-invite email is the one case where this is not a graceful degradation. An invited user has no account yet, so the accept link is the only way in — there is no “sign in and find it elsewhere” fallback the way there is for task and settings deep-links. Set FRONTEND_BASE_URL (or the Helm TRUEPPM_FRONTEND_BASE_URL value) before inviting anyone by email; any invite already sent while it was unset must be resent (Workspace → Members → pending invite → Resend) once you fix it, since resending mints a fresh token. Three things warn you it is unset outside local development: the API process itself logs a trueppm.settings warning at boot (so a plain docker run or systemd deploy with no Helm and no one running manage.py check is still told), manage.py check --deploy reports the same condition, and the Helm chart’s post-install NOTES carry it too.

  • Comment/mention snippets embedded in the body are bounded and word-wrapped before sending, so a very long unbroken string (a pasted URL, log line, or base64 blob) can’t render as one unbounded line in the recipient’s mail client.

Notification email carries a List-Unsubscribe header (RFC 2369) pointed at the recipient’s User → Settings → Notifications page. The header is only added when FRONTEND_BASE_URL is configured, since a bare relative path is not a valid header value; leave it unset and the email still sends, just without it.

It links to the login-gated preferences page, not a no-auth one-click unsubscribe endpoint — TruePPM issues no per-notification unsubscribe token, so “one click” here means one click through to sign-in and preferences, not an anonymous unsubscribe. That is a perfectly valid List-Unsubscribe; RFC 2369 does not require the target to be unauthenticated.

List-Unsubscribe-Post: List-Unsubscribe=One-Click is deliberately not sent. That companion header is an RFC 8058 promise that an unauthenticated POST to the URL unsubscribes the recipient outright, and TruePPM has no such handler. Gmail’s and Yahoo’s bulk-sender requirements are checked by exercising the POST, so advertising it without a conforming endpoint is a deliverability liability rather than a help. It returns in the same change that ships a signed unsubscribe token and its endpoint.

Outbound mail fails silently by nature: the triggering action succeeds, the in-app notification appears, and nobody finds out the relay is refusing until someone asks why they stopped getting email. TruePPM surfaces it in two places.

Workspace → Settings → System Health carries a Notification dispatcher card that reports:

StateWhat it means
crit — Credential unusableAn SMTP transport is configured but its stored password cannot be decrypted. No mail is leaving the workspace. Re-enter the password on this page.
crit — Delivery failingMail permanently failed in the last hour and nothing was delivered in it. This is what a refusing or unreachable relay looks like.
warn — N failedSome mail permanently failed, but other mail got through — usually a specific undeliverable recipient rather than a broken relay.
warn — N queuedEmails have been queued for over an hour without completing. Sends are not being attempted at all: check the Celery worker and beat.
ok — DrainingNo failures, no aging backlog, credential usable.

GET /api/v1/health/email/ serves four gauges in Prometheus text-exposition format (workspace-operator-only; scrape it with a bearer token). Like the dead-letter gauge, these are not OTLP metrics and need their own scrape job — see OpenTelemetry & OTLP export.

GaugeMeaning
trueppm_email_sends_failed_recentNotification emails that permanently failed in the last hour.
trueppm_email_sends_delivered_recentNotification emails delivered in the last hour.
trueppm_email_queue_agingEmails still queued more than an hour after the notification was created.
trueppm_email_transport_unavailable1 when the stored SMTP credential cannot be decrypted, 0 otherwise.

The Helm chart’s optional PrometheusRule (alerts.enabled=true) ships three rules against them: TruePPMEmailTransportUnavailable (critical), TruePPMEmailDeliveryFailing (critical — failures with zero deliveries), and TruePPMEmailQueueAging (warning). Thresholds are tunable under alerts.thresholds.

Leave the transport on Server default (built-in) with no EMAIL_HOST configured to run without outbound email — in-app notifications keep working and the Email & SMTP page reports the transport as not configured.