Skip to content

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.

Outbound email is not a from-scratch task. Authentik already has a working SMTP transport:

  • argocd/app-configs/authentik/secrets.yaml injects AUTHENTIK_EMAIL__HOST / PORT / USERNAME / PASSWORD / USE_TLS / FROM into authentik-server-env from Vault (cluster/smtp shared Mailgun transport + per-app email_from on cluster/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:

  1. Event-driven notifications go only to the in-app inbox. The single authentik_event_transport in the repo (enrollment-flow.tf:175, invitation_email_dummy) is mode = "local". No event ever produces an outbound email.
  2. Invitation emails use a hand-rolled SMTP path. tf/authentik/policies/send_invitation_email.py opens its own smtplib.SMTP/SMTP_SSL connection. It is synchronous (blocks the policy thread during invitation creation), untemplated, reimplements header-injection sanitization, and swallows all send failures with bare pass.

Authentik has two structurally separate outbound-email paths, confirmed against goauthentik/authentik and the Terraform provider docs:

PathReachesRecipient 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 addressescaller-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_email instead of raw smtplib. This is what “replace the invite hack” actually means.
  • default-email-transport and default-local-transport are auto-created built-in NotificationTransport objects. 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_email signature: ak_send_email(address, subject, body=None, stage=None, template=None, context=None, cc=None, bcc=None) -> bool. Returns True when the mail is queued (async); it does not confirm delivery.

Goals

  • Email a recipient group on security and audit events.
  • Email users themselves on new sign-ins.
  • Replace the invitation sender’s smtplib plumbing with ak_send_email.
  • Zero new secrets, Vault policies, ExternalSecrets, or k8s/ArgoCD changes.

Non-goals

  • Changing the SMTP transport, Mailgun account, or email_from addresses.
  • 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/authentik provider. tf/authentik/versions.tf pins ~> 2025.12 because provider 2026.2.0 regressed authentik_event_rule reads (“no value given for required property pk”). This work adds five authentik_event_rule resources — the pin MUST stay in this PR (see Risks).

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: ", default email_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 as transports = [authentik_event_transport.email.id].
  • Rules — each is an authentik_event_rule + a matcher (authentik_policy_event_matcher or authentik_policy_expression) bound via authentik_policy_binding:

    RuleTriggerMatcher typeRecipientsSeverity
    Failed loginaction = login_failedevent matchersecurity-alerts groupalert
    Suspicious requestaction = suspicious_requestevent matchersecurity-alerts groupalert
    Password changeaction = password_setevent matchersecurity-alerts groupwarning
    Config/auditmodel_created / model_updated / model_deleted on curated models, excluding the automation actorexpression policysecurity-alerts groupnotice
    New sign-inaction = loginevent matcherdestination_event_user = truenotice
  • Config/audit expression policy (the noise-control core). A simple model_* matcher would email on every terraform apply (the tf/authentik service account triggers dozens of model_updated events per GitOps run). The expression policy filters two ways, reusing the structure already proven in send_invitation_email.py:

    # pseudocode — stored in policies/match_sensitive_audit.py
    if "event" not in request.context:
    return False
    event = request.context["event"]
    if event.action not in ("model_created", "model_updated", "model_deleted"):
    return False
    model = event.context.get("model", {})
    # (a) curate sensitive models only
    SENSITIVE = {"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 False
    return True

    The exact automation actor username and the SENSITIVE set must be confirmed against live default.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 in notifications.tf per the repo convention that integration-specific groups live with their integration (groups.tf header note).
  • Seed membership by adding authentik_group.security_alerts.id to authentik_user.sean.groups in users.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 True

Delete 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), not template=. 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.

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.

  1. terraform fmt -check + terraform validate in tf/authentik.
  2. HCP speculative plan on the PR is the authority for what changes apply.
  3. Pre-flight: before merging, confirm against the deployed Authentik (2026.5.3, via ak shell / event log / docs): (a) ak_send_email exists and matches the documented signature; (b) the EventAction enum 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).
  4. Post-apply functional checks:
    • Trigger a failed login → expect an email to security-alerts members + 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 a terraform apply does 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 email custom attribute → invitee receives the email (now via ak_send_email); verify in ClickStack default.otel_logs (ServiceName authentik) that the mail task ran.
RiskMitigation
model_* audit spam from GitOps appliesExpression policy excludes the automation actor + curates models (Component 1)
ak_send_email absent/changed in 2026.5.3Pre-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 assumptionUse built-in event_notification.html (admin rules) and plain-text body (invites); no template mounting needed
Recipient has no emailNative transport discards silently (upstream behavior); group members are real users with emails
Provider regression on authentik_event_ruleDo 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.

Naturally one PR (single workspace). Recommended split for lower risk:

  • PR-A — Component 3 (invitation ak_send_email rewrite). Low-risk, immediately useful, no new resources.
  • PR-B — Components 1 & 2 (transport, rules, security-alerts group, 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.

FileChange
tf/authentik/notifications.tfnew — email transport, 5 rules, matchers, bindings, security-alerts group
tf/authentik/policies/match_sensitive_audit.pynew — config/audit expression policy
tf/authentik/policies/send_invitation_email.pyrewrite — smtplibak_send_email
tf/authentik/users.tfadd security_alerts group to authentik_user.sean.groups
docs/operations/authentik.mddocument notification rules + the two email paths
  • tf/authentik/enrollment-flow.tf (invitation_email_dummy local 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