Authentik outbound email — implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add event-driven email notifications to Authentik (native email NotificationTransport + NotificationRules for security, audit, and per-user sign-in events) and replace the invitation sender’s hand-rolled smtplib with Authentik’s ak_send_email.
Architecture: All changes are Terraform in tf/authentik (HCP workspace main-cluster-authentik, applied via GitOps on merge). Outbound SMTP is already wired through AUTHENTIK_EMAIL__* env — these resources only route events to it. No Vault, ExternalSecret, or k8s/ArgoCD changes. One prerequisite repo-tooling fix (lefthook ruff exclude) makes policy-file commits possible.
Tech Stack: Terraform + goauthentik/authentik provider (pinned ~> 2025.12), Authentik expression policies (Python fragments executed inside Authentik, not standalone modules), lefthook + ruff + cocogitto pre-commit hooks.
Verification model (read first — IaC, not unit-tested code)
Section titled “Verification model (read first — IaC, not unit-tested code)”This plan touches Terraform and Authentik config, so the usual red-green TDD loop does not apply. The verification chain is:
terraform fmt— format, thenterraform fmt -check -recursive(what lefthooklint-terraformruns).terraform validate— best-effort syntax/schema check. The backend is HCPcloud{}with dynamic Vault credentials, so a full localplan/applyis not possible —terraform -chdir=tf/authentik initneeds an HCP token (terraform login), and the live Vault creds only exist inside HCP runs. Ifinitcannot authenticate, skipvalidateand rely on step 3.- HCP speculative plan on the PR — this is the authority. On PR open, HCP TFC runs a speculative plan; review it for the expected resource additions and zero unexpected changes/replacements.
- Post-merge functional checks — see the final “Acceptance verification” section.
“Commit” steps use the commit-commands:commit skill (Conventional Commits + AI authorship byline). VCS is jj — see references/vcs-preamble.md; do not use mutating git commands.
Pre-flight (do once before Tasks 2 and 4–5): Confirm against the deployed Authentik (2026.5.3):
- (a)
ak_send_emailexists with the documented signature. - (b) The
EventActionenum strings used below are exact:login,login_failed,suspicious_request,password_set,model_created,model_updated,model_deleted. - (c) The
model_namestrings inmatch_sensitive_audit.py’sSENSITIVE_MODELS(e.g.oauth2provider,scopemapping) match the values that actually appear inevent.context["model"]["model_name"]on real audit events. - (d) The automation actor username(s) in
AUTOMATION_ACTORSmatch theevent.user["username"]produced by aterraform applyrun.
A wrong action string, model_name, or actor username silently never matches — no error, no email (or, for the actor, audit spam on every apply). Verify via kubectl -n authentik exec deploy/authentik-server -- ak shell (inspect authentik.events.models.EventAction) or the Authentik admin event log (the log entries show the exact action, model_name, and user.username payloads). Needs cluster exec / admin access; if unavailable, the values above are the best-grounded starting point — note the gap on the PR and tune after first deploy via the Acceptance verification loop.
Suggested PR split: PR-A = Tasks 1–2 (low-risk invite hardening, proves ak_send_email in prod). PR-B = Tasks 3–6 (rules, group, audit policy, docs). Each PR must carry its own doc slice (CLAUDE.md: docs update with every operational-pattern change).
Task 1: Make lefthook ruff hooks honor the policies exclude
Section titled “Task 1: Make lefthook ruff hooks honor the policies exclude”Authentik expression policies live in tf/authentik/policies/ and are excluded from ruff in pyproject.toml ([tool.ruff].exclude). But lefthook runs ruff check/ruff format with explicit staged paths, and ruff ignores exclude for explicit paths unless --force-exclude is set. Without this fix, every commit in Tasks 2 and 5 fails the pre-commit hook.
Files:
-
Modify:
lefthook.yaml(thelint-pythonandfmt-pythoncommands) -
Step 1: Confirm the failure exists
Run:
ruff check tf/authentik/policies/send_invitation_email.py >/dev/null 2>&1; echo "exit: $?"Expected: exit: 1 (this is what the hook would hit).
- Step 2: Add
--force-excludeto both ruffrun:lines only
In lefthook.yaml, edit only the two run: lines — do not rewrite the
blocks wholesale. fmt-python carries a stage_fixed: true line that MUST be
preserved (without it, ruff format fixes files on disk but doesn’t re-stage
them, breaking every subsequent Python commit). The blocks are currently:
lint-python: glob: "*.py" run: ruff check {staged_files}
fmt-python: glob: "*.py" run: ruff format {staged_files} stage_fixed: trueand must become:
lint-python: glob: "*.py" run: ruff check --force-exclude {staged_files}
fmt-python: glob: "*.py" run: ruff format --force-exclude {staged_files} stage_fixed: true- Step 3: Verify ruff now skips the excluded policy file
Run:
ruff check --force-exclude tf/authentik/policies/send_invitation_email.py >/dev/null 2>&1; echo "check: $?"ruff format --check --force-exclude tf/authentik/policies/send_invitation_email.py >/dev/null 2>&1; echo "format: $?"Expected: check: 0 and format: 0.
- Step 4: Verify non-excluded Python is still linted
Run:
ruff check --force-exclude tools/docs-audit/audit.py >/dev/null 2>&1; echo "exit: $?"Expected: exit: 0 (a real, linted file still passes — proves we didn’t disable linting globally).
- Step 5: Commit
Use the commit-commands:commit skill. Message: fix(lefthook): force-exclude ruff from authentik expression policies [hl-vs9s]
Task 2: Replace the invitation sender’s smtplib with ak_send_email
Section titled “Task 2: Replace the invitation sender’s smtplib with ak_send_email”The current policy opens its own SMTP connection synchronously and swallows failures. Rewrite it to call ak_send_email (async, global email config, observable failures). It stays an expression policy — the invitee has no user account, so the native transport cannot reach them.
Files:
- Modify (full rewrite):
tf/authentik/policies/send_invitation_email.py - Unchanged (reference only):
tf/authentik/enrollment-flow.tf:196-204(theauthentik_policy_expression.send_invitation_email+authentik_event_rule.invitation_created+invitation_email_dummytransport stay exactly as-is — the dummymode=localtransport is the structural trigger placeholder; do not touch it). - Step 1: Replace the file contents entirely
Write tf/authentik/policies/send_invitation_email.py:
# Authentik Expression Policy: Auto-send Invitation Email## Runs when an invitation is created via the Admin UI. If the invitation's# Custom attributes include an "email" field, queues the enrollment-link email# to that address via Authentik's built-in ak_send_email (async, global email# config, templated pipeline). The invitee has no user account yet, so a native# NotificationTransport cannot reach them — this MUST stay an expression policy.## Environment Variables:# AUTHENTIK_DOMAIN - Authentik domain (default: auth.fzymgc.house)# AUTHENTIK_ENROLLMENT_FLOW_SLUG - Enrollment flow slug## Usage:# 1. Directory -> Invitations -> Create# 2. Custom attributes: {"email": "user@example.com", "name": "User Name"}# 3. ak_send_email queues the invitation email automatically.
import osimport re
from django.apps import apps
AUTHENTIK_DOMAIN = os.environ.get("AUTHENTIK_DOMAIN", "auth.fzymgc.house")ENROLLMENT_FLOW_SLUG = os.environ.get( "AUTHENTIK_ENROLLMENT_FLOW_SLUG", "enrollment-with-email-verification",)
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")MAX_NAME_LENGTH = 100MAX_EMAIL_LENGTH = 254 # RFC 5321
def sanitize_text(value: str, max_length: int) -> str: """Strip control characters and truncate user-supplied text.""" if not value: return "" sanitized = re.sub(r"[\x00-\x1f\x7f]", "", value) return sanitized[:max_length].strip()
def validate_email(email: str) -> bool: """Validate email format before sending.""" if not email or len(email) > MAX_EMAIL_LENGTH: return False if not EMAIL_REGEX.match(email): return False local_part = email.split("@")[0] if ".." in local_part or local_part.startswith(".") or local_part.endswith("."): return False return True
# Only process invitation creation eventsif "event" not in request.context: return False
event = request.context["event"]if event.action != "model_created": return False
model_info = event.context.get("model", {})if model_info.get("app") != "authentik_stages_invitation": return Falseif model_info.get("model_name") != "invitation": return False
# Load the invitation from the DBInvitation = apps.get_model("authentik_stages_invitation", "invitation")try: invitation = Invitation.objects.get(pk=event.context["model"]["pk"])except Invitation.DoesNotExist: return False
custom_attrs = invitation.fixed_data or {}invited_email = sanitize_text(custom_attrs.get("email", ""), MAX_EMAIL_LENGTH)if not validate_email(invited_email): # Invalid or missing email — invitation is still valid, just no auto-email. return True
invited_name = sanitize_text(custom_attrs.get("name", ""), MAX_NAME_LENGTH)if not invited_name: invited_name = invited_email.split("@")[0][:MAX_NAME_LENGTH]
if invitation.expires: expires_friendly = invitation.expires.strftime("%a, %b %d, %Y at %I:%M %p %Z")else: expires_friendly = "Never"
invitation_url = ( f"https://{AUTHENTIK_DOMAIN}/if/flow/{ENROLLMENT_FLOW_SLUG}/?itoken={invitation.pk}")
body = f"""Hello {invited_name},
You've been invited to create an account on fzymgc.house.
Click the link below to complete your registration:{invitation_url}
This invitation expires: {expires_friendly}
If you didn't expect this invitation, you can safely ignore this email.
Best regards,The fzymgc.house Team"""
# Queue via Authentik's global email config. Returns True when QUEUED (async);# invitation creation never blocks on delivery. Failures surface in Authentik's# task/event logs (and ClickStack) instead of being silently swallowed.ak_send_email( address=invited_email, subject=f"{invited_name}, you're invited to fzymgc.house!", body=body,)return True- Step 2: Confirm the TF reference is unchanged
Run:
rg -n 'send_invitation_email.py' tf/authentik/enrollment-flow.tfExpected: one hit — expression = file("${path.module}/policies/send_invitation_email.py"). The path is unchanged, so no .tf edit is needed.
- Step 3: Verify the pre-commit hook now passes the file
Run (requires Task 1 merged/applied locally):
ruff check --force-exclude tf/authentik/policies/send_invitation_email.py >/dev/null 2>&1; echo "exit: $?"Expected: exit: 0.
- Step 4: Commit
Use commit-commands:commit. Message: refactor(authentik): invitation email via ak_send_email, drop smtplib [hl-vs9s]
- Step 5 (PR-A only): doc note
If shipping Tasks 1–2 as PR-A, add one line to docs/operations/authentik.md under the email section noting invitations now send via ak_send_email. (If shipping as one PR, Task 6 covers it.)
Task 3: Create the security-alerts group + email transport, seed admins
Section titled “Task 3: Create the security-alerts group + email transport, seed admins”Files:
- Create:
tf/authentik/notifications.tf - Modify:
tf/authentik/users.tf:21-38(Sean’sgroupslist) - Step 1: Create
tf/authentik/notifications.tfwith the group and transport
# SPDX-License-Identifier: MIT## Event-driven email notifications. The SMTP transport itself is configured via# AUTHENTIK_EMAIL__* env (argocd/app-configs/authentik/secrets.yaml); these# resources route specific events to outbound email.## Two outbound paths exist (see# docs/engineering/specs/2026-06-26-authentik-outbound-email-design.md):# - NotificationTransport mode=email -> emails EXISTING users only# (notification.user.email, chosen via destination_group / destination_event_user).# - ak_send_email(...) expression fn -> arbitrary addresses (the invitation# policy, whose invitee is not yet a user).
# Recipient group for admin-facing security + audit alerts. Seeded with admins# in users.tf but independently editable (decoupled from superuser status).resource "authentik_group" "security_alerts" { name = "security-alerts"
lifecycle { prevent_destroy = true }}
# Email notification transport. Own resource (not the built-in# default-email-transport) for a stable TF reference + custom subject prefix.resource "authentik_event_transport" "email" { name = "email" mode = "email" email_subject_prefix = "fzymgc.house Auth: " send_once = true}- Step 2: Seed the group with the admin user
In tf/authentik/users.tf, add authentik_group.security_alerts.id to authentik_user.sean.groups. The list becomes:
groups = [ # Admin groups (inherit base group access via parent hierarchy) authentik_group.agentgateway_admin.id, authentik_group.argocd_admin.id, authentik_group.cluster_admin.id, authentik_group.grafana_admin.id, authentik_group.hubble_admin.id, authentik_group.k8s_admins.id, authentik_group.mealie_admins.id, authentik_group.paperless_admin.id, authentik_group.temporal_admin.id, authentik_group.uptime_kuma_users.id, authentik_group.vault_admin.id,
# Application access (no admin group available) authentik_group.karakeep_users.id, authentik_group.komodo_users.id,
# Outbound-email security/audit alerts authentik_group.security_alerts.id, ]- Step 3: Format and verify
Run:
terraform -chdir=tf/authentik fmtterraform fmt -check -recursiveExpected: exit 0 (no diff after format).
- Step 4: Commit
Use commit-commands:commit. Message: feat(authentik): add security-alerts group + email notification transport [hl-vs9s]
Task 4: Add security + per-user sign-in notification rules
Section titled “Task 4: Add security + per-user sign-in notification rules”Four rules: failed login, suspicious request, password change → security-alerts group; new sign-in → the user themselves.
Provider-version compatibility note. The fields
destination_groupanddestination_event_userare the current names (providermaindocs). They replaced an older singlegroupfield onauthentik_event_rule. The pinned~> 2025.12provider should have them, but if the HCP speculative plan errors withUnsupported argument: destination_group, the pinned provider predates the rename: fall back togroup = authentik_group.security_alerts.idon the admin rules and, for the new-sign-in rule, also usegroup = <a group containing the users to alert>(the old schema has no per-event-user option). Group/transport references use.idhere (the repo-wide idiom, e.g.agentgateway.tf:51), not the literal “slug” the docs mention.
Files:
-
Modify:
tf/authentik/notifications.tf(append) -
Step 1: Append the four rules to
tf/authentik/notifications.tf
# --- Security events -> security-alerts group ---
resource "authentik_policy_event_matcher" "login_failed" { name = "match-login-failed" action = "login_failed"}
resource "authentik_event_rule" "login_failed" { name = "notify-login-failed" transports = [authentik_event_transport.email.id] destination_group = authentik_group.security_alerts.id severity = "alert"}
resource "authentik_policy_binding" "login_failed" { target = authentik_event_rule.login_failed.id policy = authentik_policy_event_matcher.login_failed.id order = 0}
resource "authentik_policy_event_matcher" "suspicious_request" { name = "match-suspicious-request" action = "suspicious_request"}
resource "authentik_event_rule" "suspicious_request" { name = "notify-suspicious-request" transports = [authentik_event_transport.email.id] destination_group = authentik_group.security_alerts.id severity = "alert"}
resource "authentik_policy_binding" "suspicious_request" { target = authentik_event_rule.suspicious_request.id policy = authentik_policy_event_matcher.suspicious_request.id order = 0}
resource "authentik_policy_event_matcher" "password_set" { name = "match-password-set" action = "password_set"}
resource "authentik_event_rule" "password_set" { name = "notify-password-set" transports = [authentik_event_transport.email.id] destination_group = authentik_group.security_alerts.id severity = "warning"}
resource "authentik_policy_binding" "password_set" { target = authentik_event_rule.password_set.id policy = authentik_policy_event_matcher.password_set.id order = 0}
# --- Per-user "new sign-in" -> the user who logged in ---
resource "authentik_policy_event_matcher" "login" { name = "match-login" action = "login"}
resource "authentik_event_rule" "new_signin" { name = "notify-new-signin" transports = [authentik_event_transport.email.id] destination_event_user = true severity = "notice"}
resource "authentik_policy_binding" "new_signin" { target = authentik_event_rule.new_signin.id policy = authentik_policy_event_matcher.login.id order = 0}- Step 2: Format and verify
Run:
terraform -chdir=tf/authentik fmtterraform fmt -check -recursiveExpected: exit 0.
- Step 3: Commit
Use commit-commands:commit. Message: feat(authentik): email rules for failed/suspicious login, password change, new sign-in [hl-vs9s]
Task 5: Add the config/audit expression policy + rule
Section titled “Task 5: Add the config/audit expression policy + rule”A blunt model_* matcher would email on every terraform apply (the automation token triggers many model_updated events). Use an expression policy that curates sensitive models AND excludes the automation actor.
Files:
- Create:
tf/authentik/policies/match_sensitive_audit.py - Modify:
tf/authentik/notifications.tf(append) - Step 1: Create
tf/authentik/policies/match_sensitive_audit.py
# Authentik Expression Policy: Match sensitive config/audit events.## Bound to the notify-config-audit NotificationRule. Returns True only for# create/update/delete on a curated set of security-relevant models, and ONLY# when the actor is NOT the GitOps automation account -- otherwise every# `terraform apply` against tf/authentik would email the security-alerts group.## NOTE: AUTOMATION_ACTORS and SENSITIVE_MODELS were seeded from the design and# MUST be confirmed against the live event log on first deploy (the TF token's# username and the exact model_name values).
AUDIT_ACTIONS = {"model_created", "model_updated", "model_deleted"}SENSITIVE_MODELS = { "user", "group", "token", "flow", "policy", "application", "oauth2provider", "proxyprovider", "samlprovider", "scopemapping",}AUTOMATION_ACTORS = {"akadmin", "fzy_akadmin"}
if "event" not in request.context: return False
event = request.context["event"]if event.action not in AUDIT_ACTIONS: return False
model_info = event.context.get("model", {})if model_info.get("model_name") not in SENSITIVE_MODELS: return False
actor = (event.user or {}).get("username", "")if actor in AUTOMATION_ACTORS: return False
return True- Step 2: Append the policy + rule + binding to
tf/authentik/notifications.tf
# --- Config/audit changes -> security-alerts group (noise-controlled) ---
resource "authentik_policy_expression" "match_sensitive_audit" { name = "match-sensitive-audit" expression = file("${path.module}/policies/match_sensitive_audit.py")}
resource "authentik_event_rule" "config_audit" { name = "notify-config-audit" transports = [authentik_event_transport.email.id] destination_group = authentik_group.security_alerts.id severity = "notice"}
resource "authentik_policy_binding" "config_audit" { target = authentik_event_rule.config_audit.id policy = authentik_policy_expression.match_sensitive_audit.id order = 0}- Step 3: Verify ruff skips the new policy file and TF formats
Run:
ruff check --force-exclude tf/authentik/policies/match_sensitive_audit.py >/dev/null 2>&1; echo "ruff: $?"terraform -chdir=tf/authentik fmtterraform fmt -check -recursiveExpected: ruff: 0 and fmt -check exit 0.
- Step 4: Commit
Use commit-commands:commit. Message: feat(authentik): config/audit email rule with actor + model filtering [hl-vs9s]
Task 6: Document the notification rules and the two email paths
Section titled “Task 6: Document the notification rules and the two email paths”Files:
-
Modify:
docs/operations/authentik.md(the symlinkwebsite/src/content/docs/operations/authentik.mdmirrors it automatically) -
Step 1: Add an “Outbound Email” section to
docs/operations/authentik.md
Insert after the existing email-verification section:
## Outbound Email
Authentik has **two separate outbound-email paths**. Conflating them causes wasted debugging.
| Path | Reaches | How recipients are chosen ||------|---------|---------------------------|| `NotificationTransport` (`mode=email`) | existing Authentik users only | `NotificationRule.destination_group` / `destination_event_user` || `ak_send_email(...)` expression fn | any address | caller-supplied (used by the invitation policy — invitee is not yet a user) |
Both use the global SMTP config injected via `AUTHENTIK_EMAIL__*`(`argocd/app-configs/authentik/secrets.yaml`, Vault `cluster/smtp` Mailguntransport + `cluster/authentik` `email_from`).
### Notification rules (Terraform, `tf/authentik/notifications.tf`)
| Rule | Trigger (`EventAction`) | Recipients | Severity ||------|-------------------------|------------|----------|| `notify-login-failed` | `login_failed` | `security-alerts` group | alert || `notify-suspicious-request` | `suspicious_request` | `security-alerts` group | alert || `notify-password-set` | `password_set` | `security-alerts` group | warning || `notify-config-audit` | `model_created/updated/deleted` on curated models, excluding the automation actor | `security-alerts` group | notice || `notify-new-signin` | `login` | the user who signed in | notice |
The config/audit rule uses an expression policy(`policies/match_sensitive_audit.py`) rather than a blunt event matcher, so`terraform apply` (automation actor) does not spam the alerts group. To add arecipient, add the user to the `security-alerts` group.
### Invitation emails
Sent by the `send_invitation_email` expression policy via `ak_send_email`(async, global config). It stays an expression policy because the invitee has nouser account; the `invitation_email_dummy` (`mode=local`) transport in`enrollment-flow.tf` is only the structural trigger placeholder.
### Provider pin caveat
`tf/authentik/versions.tf` pins `goauthentik/authentik ~> 2025.12`; provider`2026.2.0` regressed `authentik_event_rule` reads. Do not bump it while theseevent rules exist.- Step 2: Lint the docs
Run:
rumdl fmt --config docs/.rumdl.toml docs/operations/authentik.mdExpected: formats cleanly, exit 0.
- Step 3: Commit
Use commit-commands:commit. Message: docs(authentik): document outbound email paths + notification rules [hl-vs9s]
Acceptance verification (post-merge / post-apply)
Section titled “Acceptance verification (post-merge / post-apply)”After the HCP speculative plan looks correct and the PR(s) merge and apply:
- Speculative plan review: expected = 1 group, 1 transport, 5 event rules, 4 event matchers, 1 expression policy, 5 policy bindings added; no replacements of existing
enrollment-flow.tfresources. - Failed login: attempt a bad password →
security-alertsmembers receive an email; event appears in the Authentik event log. - Audit noise control: confirm a
terraform applyagainsttf/authentikproduces no audit emails (actor exclusion works), but a human edit to a curated model (e.g. add a user via Admin UI) produces exactly one. - New sign-in: log in successfully → the logging-in user receives a “new sign-in” email.
- Invitation: create an invitation with an
emailcustom attribute → invitee receives the email; verify the email task ran in ClickStackdefault.otel_logs(ServiceName = 'authentik'). - If any
EventActionstring or the automation-actor username from pre-flight differed from this plan, updatenotifications.tf/match_sensitive_audit.pyaccordingly and re-apply.
Files touched (summary)
Section titled “Files touched (summary)”| File | Task | Change |
|---|---|---|
lefthook.yaml | 1 | --force-exclude on ruff hooks |
tf/authentik/policies/send_invitation_email.py | 2 | rewrite → ak_send_email |
tf/authentik/notifications.tf | 3,4,5 | new — group, transport, 5 rules, matchers, bindings |
tf/authentik/users.tf | 3 | add security_alerts to Sean’s groups |
tf/authentik/policies/match_sensitive_audit.py | 5 | new — audit matcher policy |
docs/operations/authentik.md | 6 | document email paths + rules |