[HOW-TO] Send automatic email invite during invitation creation #13305
Replies: 11 comments 5 replies
-
Great guide thank you! I had to change this line to ensure it worked with AWS SES. - server.sendmail(email_sender, email_msg['To'], email_msg.as_string())
+ server.sendmail(email_from, email_msg['To'], email_msg.as_string()) |
Beta Was this translation helpful? Give feedback.
-
That's the best guide that I find |
Beta Was this translation helpful? Give feedback.
-
This guide is awesome! For some reason though, I'm having a hard time getting my policy to trigger. I even tripped the policy back a lot so that anytime any event that matches "authentik_stages_invitation" and "model_created", the policy will trigger. But looking at the logs, I see no mention of the policy triggering. I also properly set up the notification to send to admins using the default authentik notification transport just see if the notification itself would trigger, but no luck. If you know why this is happening or have some troubleshooting tips, that'd be awesome! I'd love to get this working |
Beta Was this translation helpful? Give feedback.
-
Why isn't this a feature? It suggests that we can do this, but in the end we just have invitation links and they say
Shouldn't invitations be an included feature for a SSO solution? |
Beta Was this translation helpful? Give feedback.
-
Thank you for this guide. It's a missing feature of authentik. |
Beta Was this translation helpful? Give feedback.
-
@stiw47 thank you so much! Will you be posting the third method? That is the one I am most interested in, so I can send the email from my own app outside of Authentik. Again, thank you for your guide. |
Beta Was this translation helpful? Give feedback.
-
I spent some time tinkering on the second method regarding providing an html email (as well as a plain text email fallback). Main differences are:
This version also uses Full script (modified from the original provided - much thanks to @stiw47, obviously edit for style and language as you see fit):
|
Beta Was this translation helpful? Give feedback.
-
Guys, sorry for the huge delay.... Second approach - Sending fancy HTML mail invitation with the help of policy in AuthentikI will try to be shorter, but I have problem with that 😂😂. Basically, everything is the same as in the first approach, just a
After this, you should have your Policy bound to your Notification Rule. Python script/expressionThis is the script which should be pasted into Expression field from step 4: # Import necessary modules
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from django.apps import apps
from datetime import datetime
# Define variables
AUTHENTIK_DOMAIN_NAME = "my.domain"
AUTHENTIK_ENROLMENT_FLOW_IDENTIFIER = "<identifier of your flow>"
HTML_TEMPLATE_PATH = "/templates/invitation.html"
invited_email = "[email protected]"
invited_name = "Padawan"
invited_username = "Padawan"
# Ensure the policy is triggered by an event
if "event" not in request.context:
return False
event = request.context["event"]
# Check if the event action is 'model_created' and pertains to the 'invitation' model
if event.action == "model_created" and event.context.get("model", {}).get("app") == "authentik_stages_invitation" and event.context.get("model", {}).get("model_name") == "invitation":
# Check if HTML template exist on storage
if not os.path.exists(HTML_TEMPLATE_PATH):
return False
# Retrieve the Invitation model
Invitation = apps.get_model("authentik_stages_invitation", "invitation")
# Fetch the specific invitation instance using the primary key from the event context
try:
invitation = Invitation.objects.get(pk=event.context["model"]["pk"])
except Invitation.DoesNotExist:
return False
# Extract invitation's fixed_data (custom attributes)
custom_attributes = invitation.fixed_data
if (custom_attributes.get("email") != None):
invited_email = custom_attributes.get("email")
if (custom_attributes.get("name") != None):
invited_name = custom_attributes.get("name")
if (custom_attributes.get("username") != None):
invited_username = custom_attributes.get("username")
# Extract invitation's expire date and format it to more eye friendly
expires_date = datetime.fromisoformat(str(invitation.expires))
expires_friendly = expires_date.strftime("%a, %b %d, %Y, %I:%M%p %Z")
# Create invitation URL
invitation_uid = event.context.get("model", {}).get("pk")
invitation_url = f"https://{AUTHENTIK_DOMAIN_NAME}/if/flow/{AUTHENTIK_ENROLMENT_FLOW_IDENTIFIER}/?itoken={invitation_uid}"
if not invited_email or not invited_name or not invited_username or not expires_date or not invitation_uid or not invitation_url:
return False
# Email configuration
email_sender = os.environ.get('AUTHENTIK_EMAIL__USERNAME')
email_password = os.environ.get('AUTHENTIK_EMAIL__PASSWORD')
email_from = os.environ.get('AUTHENTIK_EMAIL__FROM')
email_server = os.environ.get('AUTHENTIK_EMAIL__HOST')
email_port = int(os.environ.get('AUTHENTIK_EMAIL__PORT'))
if not email_sender or not email_password or not email_from or not email_server or not email_port:
return False
# Send the email
try:
with open(HTML_TEMPLATE_PATH, "r", encoding="utf-8") as f:
html_content = f.read()
formatted_html = (
html_content
.replace("{{ user.name }}", invited_name)
.replace("{{ url }}", invitation_url)
.replace("{{ expires }}", expires_friendly)
)
# Create the email content
email_subject = f"{AUTHENTIK_DOMAIN_NAME} | Enrollment Invitation!"
email_msg = MIMEMultipart()
email_msg['Subject'] = email_subject
email_msg['From'] = email_from
email_msg['To'] = invited_email
email_msg.attach(MIMEText(formatted_html, "html"))
if email_port == 465:
with smtplib.SMTP_SSL(email_server, email_port) as server:
server.login(email_sender, email_password)
server.sendmail(email_sender, email_msg['To'], email_msg.as_string())
elif email_port == 587:
with smtplib.SMTP(email_server, email_port) as server:
server.starttls() # Secure the connection
server.login(email_sender, email_password)
server.sendmail(email_sender, email_msg['To'], email_msg.as_string())
else:
return False
except Exception as e:
return False
return True
return False Guys, don't be mad on me, I wrote it long time ago and maybe forgot everything I changed compared to first approach (and I am lazy to compare now 😋), but if I remember correct, below part where we opening and "attaching" external .......
try:
with open(HTML_TEMPLATE_PATH, "r", encoding="utf-8") as f:
html_content = f.read()
formatted_html = (
html_content
.replace("{{ user.name }}", invited_name)
.replace("{{ url }}", invitation_url)
.replace("{{ expires }}", expires_friendly)
)
# Create the email content
email_subject = f"{AUTHENTIK_DOMAIN_NAME} | Enrollment Invitation!"
email_msg = MIMEMultipart()
email_msg['Subject'] = email_subject
email_msg['From'] = email_from
email_msg['To'] = invited_email
email_msg.attach(MIMEText(formatted_html, "html"))
...... External
|
Beta Was this translation helpful? Give feedback.
-
Third approach - Sending fancy HTML mail invitation with the help of the python script on the host, outside of Authentik containersAs we said in the first post, once when you create invitation, it ends in Authentik's PostgreSQL DB, in table < 👙 root@archmedia: docker-compose-nginx 🛃 > # docker exec -it authentik-postgresql psql -U authentik -d authentik -c "select * from authentik_stages_invitation_invitation;"
invite_uuid | expires | fixed_data | created_by_id | single_use | expiring | name | flow_id
--------------------------------------+------------------------+----------------------------------------------------------------------------------+---------------+------------+----------+--------------+--------------------------------------
9be55f5a-7ece-472c-b15a-a5477c64a365 | 2025-02-28 17:04:00+00 | {"name": "Johnny Silverhand", "email": "[email protected]", "username": "stiw47"} | 41 | t | t | ytgiuojuigyu | fdac801a-ee0c-4539-92e3-a4ab458b219e
(1 row) So we need some mechanism to monitor changes in DB, and send email event based, i.e. when new row appear in above table. So we can create Login to your Authentik's PostgreSQL DB. In my case, my DB container has name < 🚇 root@archmedia: docker-compose-authentik 🐹 > # docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}" | head -1; docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}" | grep authentik
NAMES STATUS CREATED
authentik-postgresql Up 20 hours (healthy) 20 hours ago
authentik-redis Up 2 days (healthy) 3 days ago
authentik Up 2 days (healthy) 3 weeks ago
authentik-worker Up 2 days (healthy) 3 weeks ago Enter into DB container shell (change CT name below to the name of your CT), and connect to the DB: < 👌 root@archmedia: docker-compose-authentik 🐝 > # docker exec -it authentik-postgresql bash
dfe63b50f228:/# psql -U authentik
psql (16.9)
Type "help" for help.
authentik=# Execute below queries to create a -- Step 1: Create the trigger function
CREATE OR REPLACE FUNCTION notify_insert() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('table_insert', row_to_json(NEW)::TEXT);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Step 2: Attach the trigger to your table
CREATE TRIGGER after_insert_notify
AFTER INSERT ON authentik_stages_invitation_invitation
FOR EACH ROW
EXECUTE FUNCTION notify_insert(); You can paste whole above block for And whole block for the Optionally, you can check if succeeded. See last two lines for the authentik=# \d authentik_stages_invitation_invitation
Table "public.authentik_stages_invitation_invitation"
Column | Type | Collation | Nullable | Default
---------------+--------------------------+-----------+----------+---------
invite_uuid | uuid | | not null |
expires | timestamp with time zone | | |
fixed_data | jsonb | | not null |
created_by_id | integer | | not null |
single_use | boolean | | not null |
expiring | boolean | | not null |
name | character varying(50) | | not null |
flow_id | uuid | | |
Indexes:
"authentik_stages_invitation_invitation_pkey" PRIMARY KEY, btree (invite_uuid)
"authentik_s_expires_96f4b8_idx" btree (expires)
"authentik_s_expirin_4f8096_idx" btree (expiring, expires)
"authentik_s_expirin_4f8f35_idx" btree (expiring)
"authentik_stages_invitation_invitation_created_by_id_87fe9398" btree (created_by_id)
"authentik_stages_invitation_invitation_flow_id_66945236" btree (flow_id)
"authentik_stages_invitation_invitation_name_00580941" btree (name)
"authentik_stages_invitation_invitation_name_00580941_like" btree (name varchar_pattern_ops)
Foreign-key constraints:
"authentik_stages_inv_created_by_id_87fe9398_fk_authentik" FOREIGN KEY (created_by_id) REFERENCES authentik_core_user(id) DEFERRABLE INITIALLY DEFERRED
"authentik_stages_inv_flow_id_66945236_fk_authentik" FOREIGN KEY (flow_id) REFERENCES authentik_flows_flow(flow_uuid) DEFERRABLE INITIALLY DEFERRED
Triggers:
after_insert_notify AFTER INSERT ON authentik_stages_invitation_invitation FOR EACH ROW EXECUTE FUNCTION notify_insert() And this one for the authentik=# \df
List of functions
Schema | Name | Result data type | Argument data types | Type
--------+---------------+------------------+---------------------+------
public | notify_insert | trigger | | func
(1 row) I would say, this ^ should be persistent. I mean, we are all using volumes for DB data, and I also know that this Ok, now we have something which will "send the data". Now we need something which will listen for these data, and send email on data appear, and it is a python script: import psycopg2
import select
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from datetime import datetime
# Database connection settings
DB_NAME = "authentik"
DB_USER = "authentik"
DB_PASSWORD = "2jcKXB3tw0zL43rBUN1skGhuxwFty8D5QAHHA7ZG" # change to your DB password
DB_HOST = "172.18.0.2" # Change to the IP of your DB, docker DB IP. Also be aware this script could run only from host where your containe running, cause docker IP will not be visible to some other host
DB_PORT = "5432"
# Email configuration
EMAIL_HOST = "<your email smtp server>"
EMAIL_PORT = 587 # have to be 587. If you are using 465, then python script needs to be slightly edited in order to use SMTP_SSL, not SMTP. You can combine "if email_port" logic from the script in previous post for this
EMAIL_USER = "[email protected]"
EMAIL_FROM = "Technical Support or what else <[email protected]>"
EMAIL_PASSWORD = "<your password for SMTP, on popular mail providers, this is usually called app password>"
# File paths and other
LOG_FILE = "/root/docker-compose-authentik/ak_invitations_logs/ak_invitations.log" # Change path as needed
HTML_TEMPLATE_PATH = "/root/docker-compose-authentik/custom-templates/invitation.html"
AUTHENTIK_DOMAIN_NAME = "my.domain"
AUTHENTIK_ENROLMENT_FLOW_IDENTIFIER = "<identifier of your flow>"
def send_invitation_email(recipient_email, name, invite_uuid, expires):
"""Send an email using an HTML template"""
try:
# Read the HTML template
with open(HTML_TEMPLATE_PATH, "r", encoding="utf-8") as f:
html_content = f.read()
# Construct the URL
invitation_url = f"https://{AUTHENTIK_DOMAIN_NAME}/if/flow/{AUTHENTIK_ENROLMENT_FLOW_IDENTIFIER}/?itoken={invitation_uid}"
# Replace placeholders in the template
expires_date = datetime.fromisoformat(expires)
expires_friendly = expires_date.strftime("%a, %b %d, %Y, %I:%M%p %Z")
formatted_html = (
html_content
.replace("{{ user.name }}", name)
.replace("{{ url }}", invitation_url)
.replace("{{ expires }}", expires_friendly)
)
# Create the email message
msg = MIMEMultipart()
msg["From"] = EMAIL_FROM
msg["To"] = recipient_email
msg["Subject"] = "stiw47 | Enrollment Invitation"
msg.attach(MIMEText(formatted_html, "html")) # Ensure HTML content type
# Send the email
server = smtplib.SMTP(EMAIL_HOST, EMAIL_PORT)
server.starttls()
server.login(EMAIL_USER, EMAIL_PASSWORD)
server.sendmail(EMAIL_USER, recipient_email, msg.as_string())
server.quit()
print(f"Email successfully sent to {recipient_email}")
except Exception as e:
print(f"Failed to send email to {recipient_email}: {e}")
def listen_for_inserts():
"""Connect to PostgreSQL and listen for insert notifications."""
conn = psycopg2.connect(
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port=DB_PORT
)
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
cur.execute("LISTEN table_insert;") # Listen for the trigger notification
print("Listening for new inserts in 'authentik_stages_invitation_invitation' table...")
while True:
if select.select([conn], [], [], 5) == ([], [], []):
continue
conn.poll()
while conn.notifies:
notify = conn.notifies.pop(0)
row_data = notify.payload
# Write log
with open(LOG_FILE, "a") as f:
f.write(row_data + "\n") # Append JSON data of inserted row to file
# Parse JSON and extract recipient details
try:
row_json = json.loads(row_data)
recipient_email = row_json["fixed_data"]["email"]
name = row_json["fixed_data"]["name"]
invite_uuid = row_json["invite_uuid"]
expires = row_json["expires"]
# Send email
send_invitation_email(recipient_email, name, invite_uuid, expires)
except (json.JSONDecodeError, KeyError) as e:
print(f"Error processing log entry: {e}")
if __name__ == "__main__":
listen_for_inserts() Several notes (you know me 😂):
Now we have a problem. Most of the times, when docker container restarts (actually, I think each time), it's docker IP being changed. And in script, we are listening for the DB_HOST = "172.18.0.2" So I slightly edited networks:
docker-compose-authentik_default:
external: true
services:
postgresql:
container_name: authentik-postgresql
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- database:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${PG_DB:-authentik}
env_file:
- .env
networks:
docker-compose-authentik_default:
ipv4_address: 172.18.0.2 Side note, I done the same for other Authentik containers as well, so all of them have fixed IPs, e.g. (but this is not relevant, nor mandatory): networks:
docker-compose-authentik_default:
ipv4_address: 172.18.0.5
networks:
docker-compose-authentik_default:
ipv4_address: 172.18.0.3
networks:
docker-compose-authentik_default:
ipv4_address: 172.18.0.4 Ok, now we have only one remain problem, since in order that this works, you need manually to start the script, and of course to do this after each host reboot, so I created systemd service: < 🐡 root@archmedia: docker-compose-authentik 🚸 > # cat /etc/systemd/system/ak-invitations.service
[Unit]
Description=PostgreSQL Insert Logger and mail sender for Authentik Invitations
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python /root/docker-compose-authentik/ak_invitations_logs/ak_invitations.py
Restart=always
RestartSec=5
StandardOutput=append:/root/docker-compose-authentik/ak_invitations_logs/ak_invitations_insert_logger.log
StandardError=append:/root/docker-compose-authentik/ak_invitations_logs/ak_invitations_insert_logger.log
[Install]
WantedBy=multi-user.target More logging ^ 😁 And set it to run on boot: systemctl daemon-reload
systemctl enable --now ak-invitations Check carefully my paths provided in the script and where else, and replace with your paths. Thx |
Beta Was this translation helpful? Give feedback.
-
Hi, Thanks |
Beta Was this translation helpful? Give feedback.
-
For anyone wondering about group membership (@sbisbilo)
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
First of all I would like to say that I am not dev at all. I used google, used little bit help of GPT, made below scripts, and they are working for my use case. Of course, I am open for suggestions and improvements, if anybody is interested to comment.
TBH, I was surprised when I saw that we have possibility to create invitation enrollment URLs, and there is no out of box option to send created URL automatically via email (or some other channel) to the invited person. Similar
what the heck
surprise as for there is no simple option (out of box) for users to upload their avatar 😂. Luckily, we have this guy @drpetersen who solved avatar mystery here: #6824 (BIG THANKS!), but this is now some other story, and not related with my post. However, no matter, if we ignore these odd's, really good peace of software, and I can see that we getting new features with updates - THANKS FOR THAT! I also understand that Authentik guys most probably cannot implement everything at once.I will describe how I accomplished to automatically send email invitation to invited person when invitation is created. This email also contain invitation enrollment URL and other important data. I will describe 3 different approaches:
This guide presume that you already have some enrollment flow, so that invitation URL will open enrollment flow for invited user. I made my enrollment flow with the help of this guide: https://youtu.be/mGOTpRfulfQ?t=424 Please note that first half of this video is about how to enable "Sign Up" link on Authentik login page. This way, everyone from the internet would be able to register in your Authentik. I have some feeling that most of the home lab users wouldn't want that, rather would want that invited people only could register. If you share my opinion/use case, then ignore first part of the video and watch second part, i.e. from the link timestamp. If you already have your enrollment flow - ignore provided video at all.
Ok, once when you have enrollment flow, let's setup automatic email invite. This also presume that you already have your email parameters (username, password, etc.) loaded as environment variables in Authentik, and your email is working. Here is a little background:
So, ok, regarding email environment variables I mentioned before, this is what I have in my
.env
file (and what is related to email). I will use here some dummy domains, passwords, etc. of course:First approach - Sending text mail invitation with the help of policy in Authentik
Events
>Notification Rules
and create dummy empty fake notification rule like this:As you can see, this ^ rule doing nothing. But I don't know some other way that I can attach policy which would be executed when invitation is created. And in this certain case, policy (going next) is python script which:
app == authentik_stages_invitation
andmodel_name == invitation
, this means invitation createdExpand previously created Notification Rule, and go to

Create and bind Policy
(I already have policy bound on screenshot):Choose

Expression Policy
andNext
:Give Policy the name, paste python script I will provide in next steps into

Expression
field, and clickNext
:Click

Finish
on last screen, you can leave all default:After this, you should have your Policy bound to your Notification Rule.
Python script/expression
This is the script which should be pasted into
Expression
field from step 4:Few more notes
Lines 8-11 - change variable values to your own values:
AUTHENTIK_DOMAIN_NAME
is fixed variable and will not be changed anywhere during script execution.For other 3 - as soon as I explain how to use this and how to create invitation in order that mail being sent to some desired address, I will also explain why I set them initially to above values.
How to create invitation in order that invited party get email
Go to

Directory
>Invitations
>Create
, and except of invitationName
,Expires
andFlow
, fill also some data inCustom attributes
. This is important, because the script/policy will get the value of theemail
field fromCustom attributes
and this will be receiver email address for your invitation. I am usually using following data (not mandatory, will explain):At this point, you are done. Once when you click
Create
, if you followed previous guide carefully and set everything without mistake,[email protected]
should receive the email with invitation URL and rest of the basic body text and subject from this part of the script:As said, any of above fields is not mandatory, but I'm using

username
andname
cause I figured out if I fill it like this, then Sign Up form will be already pre-populated with respective values, once when invited user open invitation URL, like below:This not limiting
username
,name
not even theemail
to ones pre-populated. Enrolled user can change any of those (and in my flow, user will have email verification stage), but people are usually lazy and not thinking too much outside of box, so in 99% cases I know I will find new user as I set it inCustom attributes
.Little more background
Once when you create invitation, it ends in Authentik's PostgreSQL DB, in table
authentik_stages_invitation_invitation
:As you can see, all your
Custom attributes
ends in columnfixed_data
. So below part of the Python script is in charge to pull them:So what I said few paragraphs before, I am setting above variables to some initial values in order that email has some
Hey <name>
if I decide to skip the name, or that email goes to my inbox if I decide to skip email. TBH, now when I'm looking, I am not usingusername
anywhere in email 😂😂.One more thing: From my knowledge, SMTP IMAP mails are using either 465 or 587 port. If anyone anywhere is using some other port except of these two (I don't think so, right?), then script would need to be edited.
I think this is enough for now, probably already starts to be confusing. I will write second and third approaches most probably tomorrow, and much more short. Basically, setting workflow for second approach is the same, with the difference that you have prepared HTML mail template on storage, and python script should be slightly edited to replace text placeholders such as
{{ url }}
,{{ user.name }}
... with actual data, and send this HTML template instead of plain text.Be free to ask if something is not clear, and I would really like to hear opinions, suggestions, etc.
Cheers
Beta Was this translation helpful? Give feedback.
All reactions