Skip to content

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:

  1. terraform fmt — format, then terraform fmt -check -recursive (what lefthook lint-terraform runs).
  2. terraform validate — best-effort syntax/schema check. The backend is HCP cloud{} with dynamic Vault credentials, so a full local plan/apply is not possibleterraform -chdir=tf/authentik init needs an HCP token (terraform login), and the live Vault creds only exist inside HCP runs. If init cannot authenticate, skip validate and rely on step 3.
  3. HCP speculative plan on the PRthis is the authority. On PR open, HCP TFC runs a speculative plan; review it for the expected resource additions and zero unexpected changes/replacements.
  4. 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_email exists with the documented signature.
  • (b) The EventAction enum strings used below are exact: login, login_failed, suspicious_request, password_set, model_created, model_updated, model_deleted.
  • (c) The model_name strings in match_sensitive_audit.py’s SENSITIVE_MODELS (e.g. oauth2provider, scopemapping) match the values that actually appear in event.context["model"]["model_name"] on real audit events.
  • (d) The automation actor username(s) in AUTOMATION_ACTORS match the event.user["username"] produced by a terraform apply run.

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 (the lint-python and fmt-python commands)

  • Step 1: Confirm the failure exists

Run:

Terminal window
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-exclude to both ruff run: 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: true

and 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:

Terminal window
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:

Terminal window
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 (the authentik_policy_expression.send_invitation_email + authentik_event_rule.invitation_created + invitation_email_dummy transport stay exactly as-is — the dummy mode=local transport 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 os
import 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 = 100
MAX_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 events
if "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 False
if model_info.get("model_name") != "invitation":
return False
# Load the invitation from the DB
Invitation = 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:

Terminal window
rg -n 'send_invitation_email.py' tf/authentik/enrollment-flow.tf

Expected: 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):

Terminal window
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’s groups list)
  • Step 1: Create tf/authentik/notifications.tf with the group and transport
tf/authentik/notifications.tf
# 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:

Terminal window
terraform -chdir=tf/authentik fmt
terraform fmt -check -recursive

Expected: 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_group and destination_event_user are the current names (provider main docs). They replaced an older single group field on authentik_event_rule. The pinned ~> 2025.12 provider should have them, but if the HCP speculative plan errors with Unsupported argument: destination_group, the pinned provider predates the rename: fall back to group = authentik_group.security_alerts.id on the admin rules and, for the new-sign-in rule, also use group = <a group containing the users to alert> (the old schema has no per-event-user option). Group/transport references use .id here (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:

Terminal window
terraform -chdir=tf/authentik fmt
terraform fmt -check -recursive

Expected: 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:

Terminal window
ruff check --force-exclude tf/authentik/policies/match_sensitive_audit.py >/dev/null 2>&1; echo "ruff: $?"
terraform -chdir=tf/authentik fmt
terraform fmt -check -recursive

Expected: 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 symlink website/src/content/docs/operations/authentik.md mirrors 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` Mailgun
transport + `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 a
recipient, 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 no
user 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 these
event rules exist.
  • Step 2: Lint the docs

Run:

Terminal window
rumdl fmt --config docs/.rumdl.toml docs/operations/authentik.md

Expected: 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.tf resources.
  • Failed login: attempt a bad password → security-alerts members receive an email; event appears in the Authentik event log.
  • Audit noise control: confirm a terraform apply against tf/authentik produces 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 email custom attribute → invitee receives the email; verify the email task ran in ClickStack default.otel_logs (ServiceName = 'authentik').
  • If any EventAction string or the automation-actor username from pre-flight differed from this plan, update notifications.tf / match_sensitive_audit.py accordingly and re-apply.
FileTaskChange
lefthook.yaml1--force-exclude on ruff hooks
tf/authentik/policies/send_invitation_email.py2rewrite → ak_send_email
tf/authentik/notifications.tf3,4,5new — group, transport, 5 rules, matchers, bindings
tf/authentik/users.tf3add security_alerts to Sean’s groups
tf/authentik/policies/match_sensitive_audit.py5new — audit matcher policy
docs/operations/authentik.md6document email paths + rules