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

@@ -72,6 +72,16 @@ class Settings(BaseSettings):
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
jwt_expire_days: int = 30
# SMTP — for sending invite emails
smtp_server: str = ""
smtp_port: int = 465
smtp_username: str = ""
smtp_password: str = ""
smtp_from_email: str = "noreply@oneskyit.com"
smtp_from_name: str = "Cortex"
# Base URL used in invite links (no trailing slash)
cortex_base_url: str = "https://cortex.dgrzone.com"
host: str = "0.0.0.0"
port: int = 8000

107
cortex/email_utils.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Email utilities for Cortex — invite links and future notifications.
Uses smtplib.SMTP_SSL (port 465). Both plain-text and HTML bodies are sent.
SMTP credentials come from config.settings (set in .env).
"""
import logging
import smtplib
import ssl
from email.headerregistry import Address
from email.message import EmailMessage
from config import settings
logger = logging.getLogger(__name__)
def send_email(
to_email: str,
subject: str,
body_html: str,
body_text: str,
to_name: str = "",
) -> bool:
"""
Send an email via SMTP_SSL.
Returns True on success, False on any failure.
Logs errors but never raises — callers can check the return value.
"""
if not settings.smtp_server:
logger.error("SMTP not configured (SMTP_SERVER is empty)")
return False
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = Address(
display_name=settings.smtp_from_name,
addr_spec=settings.smtp_from_email,
)
msg["To"] = Address(
display_name=to_name or to_email,
addr_spec=to_email,
)
msg.set_content(body_text)
msg.add_alternative(f"<html><body>{body_html}</body></html>", subtype="html")
logger.info("sending email to %s%s", to_email, subject)
try:
ctx = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_server, settings.smtp_port, context=ctx) as server:
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(msg)
logger.info("email sent to %s", to_email)
return True
except Exception as e:
logger.error("failed to send email to %s: %s", to_email, e)
return False
def send_invite_email(to_email: str, username: str, token: str, to_name: str = "") -> bool:
"""Send a Cortex invite link to a new user."""
url = f"{settings.cortex_base_url}/setup/{token}"
body_text = f"""\
You've been invited to Cortex.
Click the link below to set your password and create your persona:
{url}
This link expires in 72 hours and can only be used once.
— Cortex
"""
body_html = f"""\
<p>You've been invited to <strong>Cortex</strong>.</p>
<p>Click the link below to set your password and create your persona:</p>
<p><a href="{url}" style="
display:inline-block;
padding:10px 20px;
background:#7c3aed;
color:#fff;
text-decoration:none;
border-radius:6px;
font-family:sans-serif;
font-size:15px;
">Set up my account →</a></p>
<p style="font-size:13px;color:#666;">
Or copy this link:<br>
<code>{url}</code>
</p>
<p style="font-size:12px;color:#999;">
This link expires in 72 hours and can only be used once.
</p>
"""
return send_email(
to_email=to_email,
subject="You've been invited to Cortex",
body_html=body_html,
body_text=body_text,
to_name=to_name or username,
)

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: