Authentik Outbound Email Design
Date: 2026-06-26
Bead: hl-vs9s
Purpose: Add event-driven email notifications to Authentik (native email
NotificationTransport + NotificationRules) and harden the existing custom
invitation-email sender by replacing raw smtplib with Authentik’s blessed
ak_send_email expression function.
Background — what already exists
Section titled “Background — what already exists”Outbound email is not a from-scratch task. Authentik already has a working SMTP transport:
argocd/app-configs/authentik/secrets.yamlinjectsAUTHENTIK_EMAIL__HOST / PORT / USERNAME / PASSWORD / USE_TLS / FROMintoauthentik-server-envfrom Vault (cluster/smtpshared Mailgun transport + per-appemail_fromoncluster/authentik).- This already powers flow-stage emails: the email-verification stage
(
tf/authentik/enrollment-flow.tf) and “Forgot Password” recovery.
Two gaps remain, and they are the subject of this design:
- Event-driven notifications go only to the in-app inbox. The single
authentik_event_transportin the repo (enrollment-flow.tf:175,invitation_email_dummy) ismode = "local". No event ever produces an outbound email. - Invitation emails use a hand-rolled SMTP path.
tf/authentik/policies/send_invitation_email.pyopens its ownsmtplib.SMTP/SMTP_SSLconnection. It is synchronous (blocks the policy thread during invitation creation), untemplated, reimplements header-injection sanitization, and swallows all send failures with barepass.
Key upstream facts (grounding)
Section titled “Key upstream facts (grounding)”Authentik has two structurally separate outbound-email paths, confirmed
against goauthentik/authentik and the Terraform provider docs:
| Path | Reaches | Recipient selection |
|---|---|---|
NotificationTransport (mode=email) | only existing Authentik users (notification.user.email) | NotificationRule.destination_group (group members) and/or destination_event_user (the user who triggered the event) |
ak_send_email(address, subject, body|template, …) | arbitrary addresses | caller-supplied; uses the global AUTHENTIK_EMAIL config + async task queue + Authentik email templates |
Consequences for this design:
- An invitee has no user account yet, so the native transport cannot
reach them. The invitation case must stay an expression policy — but it should
call
ak_send_emailinstead of rawsmtplib. This is what “replace the invite hack” actually means. default-email-transportanddefault-local-transportare auto-created built-inNotificationTransportobjects. We could reference the built-in, but this design creates its own email transport resource for IaC stability and a custom subject prefix (see Component 1).ak_send_emailsignature:ak_send_email(address, subject, body=None, stage=None, template=None, context=None, cc=None, bcc=None) -> bool. ReturnsTruewhen the mail is queued (async); it does not confirm delivery.
Goals / Non-goals
Section titled “Goals / Non-goals”Goals
- Email a recipient group on security and audit events.
- Email users themselves on new sign-ins.
- Replace the invitation sender’s
smtplibplumbing withak_send_email. - Zero new secrets, Vault policies, ExternalSecrets, or k8s/ArgoCD changes.
Non-goals
- Changing the SMTP transport, Mailgun account, or
email_fromaddresses. - Custom HTML email templates (would require mounting a template volume into the Authentik container — a separate, larger effort; noted as future work).
- “Suspicious login only” / new-device detection (Authentik has no native known-IP memory; see Risks).
- Bumping the
goauthentik/authentikprovider.tf/authentik/versions.tfpins~> 2025.12because provider2026.2.0regressedauthentik_event_rulereads (“no value given for required property pk”). This work adds fiveauthentik_event_ruleresources — the pin MUST stay in this PR (see Risks).
Architecture
Section titled “Architecture”All changes live in a single Terraform workspace, tf/authentik (HCP
main-cluster-authentik), applied via GitOps on merge. No Vault, no
ExternalSecret, no ArgoCD/k8s changes — the SMTP env is already live and
ak_send_email reuses it.
┌─────────────────────────────────────────────┐ Event ───────▶│ NotificationRule (matcher policy + binding) │ (login_failed, │ transports = [email transport] │ model_*, …) │ destination_group / destination_event_user│ └───────────────┬─────────────────────────────┘ │ (recipient = existing user) ▼ authentik_event_transport (mode=email) ──▶ AUTHENTIK_EMAIL (Mailgun)
Invitation create ─▶ expression policy ─▶ ak_send_email(address=invitee) ─▶ AUTHENTIK_EMAIL (invitee is NOT a user → cannot use the transport path above)Component 1 — Email notification transport + rules
Section titled “Component 1 — Email notification transport + rules”New file tf/authentik/notifications.tf.
-
authentik_event_transport "email"—mode = "email",email_subject_prefix = "fzymgc.house Auth: ", defaultemail_template = "email/event_notification.html",send_once = true.- Decision: create our own resource rather than referencing the built-in
default-email-transport, for a stable TF-managed reference and a custom subject prefix. Rules reference it astransports = [authentik_event_transport.email.id].
- Decision: create our own resource rather than referencing the built-in
-
Rules — each is an
authentik_event_rule+ a matcher (authentik_policy_event_matcherorauthentik_policy_expression) bound viaauthentik_policy_binding:Rule Trigger Matcher type Recipients Severity Failed login action = login_failedevent matcher security-alertsgroupalertSuspicious request action = suspicious_requestevent matcher security-alertsgroupalertPassword change action = password_setevent matcher security-alertsgroupwarningConfig/audit model_created/model_updated/model_deletedon curated models, excluding the automation actorexpression policy security-alertsgroupnoticeNew sign-in action = loginevent matcher destination_event_user = truenotice -
Config/audit expression policy (the noise-control core). A simple
model_*matcher would email on everyterraform apply(thetf/authentikservice account triggers dozens ofmodel_updatedevents per GitOps run). The expression policy filters two ways, reusing the structure already proven insend_invitation_email.py:# pseudocode — stored in policies/match_sensitive_audit.pyif "event" not in request.context:return Falseevent = request.context["event"]if event.action not in ("model_created", "model_updated", "model_deleted"):return Falsemodel = event.context.get("model", {})# (a) curate sensitive models onlySENSITIVE = {"user", "group", "token", "flow", "policy","oauth2provider", "proxyprovider", "samlprovider","application"}if model.get("model_name") not in SENSITIVE:return False# (b) exclude the GitOps automation actor (prevents apply-spam)actor = (event.user or {}).get("username", "")if actor in ("fzy_akadmin", "akadmin"):return Falsereturn TrueThe exact automation actor username and the
SENSITIVEset must be confirmed against livedefault.otel_logs/ the Authentik event log during implementation. The list above is the starting point, not the final word.
Component 2 — security-alerts group, seeded with admins
Section titled “Component 2 — security-alerts group, seeded with admins”authentik_group "security_alerts"(name = "security-alerts"), placed innotifications.tfper the repo convention that integration-specific groups live with their integration (groups.tfheader note).- Seed membership by adding
authentik_group.security_alerts.idtoauthentik_user.sean.groupsinusers.tf. The group stays independently editable (others can be added/removed without touching superuser status).
Component 3 — Harden the invitation sender
Section titled “Component 3 — Harden the invitation sender”Rewrite tf/authentik/policies/send_invitation_email.py:
Keep the event filtering (model_created on
authentik_stages_invitation.invitation), DB lookup, email extraction +
validate_email, name fallback, expiry formatting, and URL construction.
Replace lines ~124–184 (all SMTP plumbing) with a single call:
ak_send_email( address=invited_email, subject=f"{invited_name}, you're invited to fzymgc.house!", body=body_text, # existing plain-text body, unchanged)return TrueDelete import smtplib, from email.mime.text import MIMEText, the
AUTHENTIK_EMAIL__* env reads, the port parsing, the SMTP_SSL/STARTTLS +
login + sendmail blocks, and the SMTP/network exception handlers.
Net effect: async (no longer blocks invitation creation), uses Authentik’s
own mail pipeline + global config, failures become observable in Authentik’s
task/event logs and ClickStack instead of a silent pass, and ~80 lines of
security-sensitive custom code are removed. Light sanitize_header_value on the
name (used in subject/body) is retained as cheap defense-in-depth, though
ak_send_email builds the message safely so header injection is no longer the
threat it was with raw smtplib.
- Decision: use
body=(plain text), nottemplate=. A custom template would require mounting a template volume into the Authentik image — out of scope.
The existing invitation_email_dummy transport and invitation_created event
rule stay. They are not the SMTP path being removed — the event rule is what
triggers this expression policy, and Authentik requires every event rule to
have ≥1 transport, so the mode=local dummy is a structural placeholder
(enrollment-flow.tf:169-186). This rule cannot be converted to the new email
transport: the invitation_created event’s user is the admin who created the
invite, not the invitee, so a native transport would email the wrong person.
The dummy produces a harmless, ignored in-app notification; leave it as-is.
Data flow & secrets
Section titled “Data flow & secrets”No secret changes. AUTHENTIK_EMAIL__* is already injected; both the native
transport and ak_send_email consume the global config. No new Vault path, no
ExternalSecret edit, no Vault policy update.
Testing & verification
Section titled “Testing & verification”terraform fmt -check+terraform validateintf/authentik.- HCP speculative plan on the PR is the authority for what changes apply.
- Pre-flight: before merging, confirm against the deployed Authentik
(
2026.5.3, viaak shell/ event log / docs): (a)ak_send_emailexists and matches the documented signature; (b) theEventActionenum values used by the matchers are exact —login,login_failed,suspicious_request,password_set,model_created,model_updated,model_deleted. A wrong action string silently never matches (no error, no email). - Post-apply functional checks:
- Trigger a failed login → expect an email to
security-alertsmembers + an entry in the Authentik event log. - Run a benign
model_*change as a human (not the automation token) on a curated model → expect one audit email; confirm aterraform applydoes not generate audit emails (actor-exclusion works). - Log in successfully → expect a “new sign-in” email to the logging-in user.
- Create an invitation with an
emailcustom attribute → invitee receives the email (now viaak_send_email); verify in ClickStackdefault.otel_logs(ServiceName authentik) that the mail task ran.
- Trigger a failed login → expect an email to
Risks & mitigations
Section titled “Risks & mitigations”| Risk | Mitigation |
|---|---|
model_* audit spam from GitOps applies | Expression policy excludes the automation actor + curates models (Component 1) |
ak_send_email absent/changed in 2026.5.3 | Pre-flight verification gate before merging Component 3 (Testing #3) |
| Login-alert volume (per-login, not per-new-device) | Acceptable for a household IdP; future expression policy keyed on user.attributes can scope to new IPs |
| Email-template assumption | Use built-in event_notification.html (admin rules) and plain-text body (invites); no template mounting needed |
| Recipient has no email | Native transport discards silently (upstream behavior); group members are real users with emails |
Provider regression on authentik_event_rule | Do not bump the goauthentik/authentik provider in this PR. versions.tf pins ~> 2025.12; 2026.2.0 regressed event-rule reads (“required property pk”). Adding event rules under a bumped provider would break plan/apply. |
Rollout / phasing
Section titled “Rollout / phasing”Naturally one PR (single workspace). Recommended split for lower risk:
- PR-A — Component 3 (invitation
ak_send_emailrewrite). Low-risk, immediately useful, no new resources. - PR-B — Components 1 & 2 (transport, rules,
security-alertsgroup, Sean membership).
Either order is safe; PR-A first lets the ak_send_email assumption be proven
in production before the larger rule set lands.
Files changed
Section titled “Files changed”| File | Change |
|---|---|
tf/authentik/notifications.tf | new — email transport, 5 rules, matchers, bindings, security-alerts group |
tf/authentik/policies/match_sensitive_audit.py | new — config/audit expression policy |
tf/authentik/policies/send_invitation_email.py | rewrite — smtplib → ak_send_email |
tf/authentik/users.tf | add security_alerts group to authentik_user.sean.groups |
docs/operations/authentik.md | document notification rules + the two email paths |
References
Section titled “References”tf/authentik/enrollment-flow.tf(invitation_email_dummylocal transport)tf/authentik/policies/send_invitation_email.py(current SMTP hack)argocd/app-configs/authentik/secrets.yaml(AUTHENTIK_EMAIL__*injection)- goauthentik/authentik — NotificationRule / NotificationTransport /
ak_send_email - goauthentik/terraform-provider-authentik —
authentik_event_rule,authentik_event_transport,authentik_policy_event_matcher,authentik_policy_binding