feat: SMTP email support for invite links + profile.json for user email storage

- email_utils.py: send_email() via smtplib.SMTP_SSL (port 465, same server
  as AE API); send_invite_email() renders plain-text + HTML invite template
- config.py: smtp_server, smtp_port, smtp_username, smtp_password,
  smtp_from_email, smtp_from_name, cortex_base_url settings
- manage_passwords.py:
  - profile.json helpers (get/set email stored in home/{username}/profile.json)
  - invite command now accepts optional email arg, sends invite automatically;
    falls back to stored email; prints link either way
  - new 'email' command to store/update a user's email address
  - 'list' command now shows email alongside password status
- .env.default: SMTP_* and CORTEX_BASE_URL documented

Usage after adding SMTP_PASSWORD to .env:
  python manage_passwords.py invite holly holly@example.com
  → generates token, stores email, sends invite, prints link as fallback

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 23:19:09 -04:00
parent 46b65d087c
commit 69f38ca7dc
4 changed files with 219 additions and 18 deletions

View File

@@ -3,13 +3,15 @@
Password and invite management for Cortex users.
Usage:
python manage_passwords.py set <username> # prompt for password
python manage_passwords.py set <username> <pass> # set directly (avoid in shell history)
python manage_passwords.py check <username> # test a password interactively
python manage_passwords.py list # show which users have a password set
python manage_passwords.py invite <username> # generate a one-time setup link
python manage_passwords.py set <username> # prompt for password
python manage_passwords.py set <username> <pass> # set directly (avoid in shell history)
python manage_passwords.py check <username> # test a password interactively
python manage_passwords.py list # show users, passwords, and emails
python manage_passwords.py invite <username> [email] # generate + optionally email invite link
python manage_passwords.py email <username> <email> # store/update an email address
"""
import json
import sys
import getpass
@@ -21,6 +23,44 @@ from persona import list_users
from config import settings
# ---------------------------------------------------------------------------
# Profile helpers (home/{username}/profile.json)
# ---------------------------------------------------------------------------
def _profile_path(username: str):
return settings.home_root() / username / "profile.json"
def get_profile(username: str) -> dict:
p = _profile_path(username)
if not p.exists():
return {}
try:
return json.loads(p.read_text())
except Exception:
return {}
def save_profile(username: str, profile: dict) -> None:
p = _profile_path(username)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(profile, indent=2) + "\n")
def get_email(username: str) -> str | None:
return get_profile(username).get("email")
def set_email(username: str, email: str) -> None:
profile = get_profile(username)
profile["email"] = email
save_profile(username, profile)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_set(args):
if not args:
print("Usage: manage_passwords.py set <username> [password]")
@@ -52,29 +92,61 @@ def cmd_check(args):
def cmd_list(_args):
for user in list_users():
has = _auth_path(user).exists()
status = "✓ password set" if has else "✗ no password"
print(f" {user:<20} {status}")
users = list_users()
if not users:
print(" No users found in home/")
return
for user in users:
has_pw = "✓ pw" if _auth_path(user).exists() else "✗ pw"
email = get_email(user) or ""
print(f" {user:<20} {has_pw} {email}")
def cmd_email(args):
if len(args) < 2:
print("Usage: manage_passwords.py email <username> <email>")
sys.exit(1)
username, email = args[0], args[1]
set_email(username, email)
print(f"Email saved for {username!r}: {email}")
def cmd_invite(args):
if not args:
print("Usage: manage_passwords.py invite <username>")
print("Usage: manage_passwords.py invite <username> [email]")
sys.exit(1)
username = args[0]
# Create the user directory if it doesn't exist yet
user_dir = settings.home_root() / username
user_dir.mkdir(parents=True, exist_ok=True)
username = args[0]
email_arg = args[1] if len(args) >= 2 else None
# Ensure user directory exists
(settings.home_root() / username).mkdir(parents=True, exist_ok=True)
# Store email if provided
if email_arg:
set_email(username, email_arg)
# Use stored email if no arg given
to_email = email_arg or get_email(username)
token = create_invite(username)
# Try to read host from settings for a helpful URL
host = "cortex.dgrzone.com"
url = f"{settings.cortex_base_url}/setup/{token}"
print(f"\nInvite link for {username!r}:")
print(f" https://{host}/setup/{token}\n")
print(f" {url}\n")
print("Link expires in 72 hours. One-time use.")
print("Send this to the user — they'll set their own password and create a persona.\n")
if to_email:
from email_utils import send_invite_email
print(f"Sending invite email to {to_email}...")
ok = send_invite_email(to_email=to_email, username=username, token=token)
if ok:
print("Email sent.")
else:
print("Email failed — check SMTP settings. Link above is still valid.")
else:
print("No email address on file — send the link manually.")
print("Tip: python manage_passwords.py invite <username> <email> to email it next time.\n")
if __name__ == "__main__":
@@ -91,6 +163,8 @@ if __name__ == "__main__":
cmd_check(rest)
elif command == "list":
cmd_list(rest)
elif command == "email":
cmd_email(rest)
elif command == "invite":
cmd_invite(rest)
else: