From 69f38ca7dce5c2f506960e2ba2aeff01859429e3 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 20 Mar 2026 23:19:09 -0400 Subject: [PATCH] feat: SMTP email support for invite links + profile.json for user email storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.default | 10 ++++ cortex/config.py | 10 ++++ cortex/email_utils.py | 107 ++++++++++++++++++++++++++++++++++++ cortex/manage_passwords.py | 110 +++++++++++++++++++++++++++++++------ 4 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 cortex/email_utils.py diff --git a/.env.default b/.env.default index 13853d6..e41b5bd 100644 --- a/.env.default +++ b/.env.default @@ -18,6 +18,16 @@ USER_NAME=Scott JWT_SECRET=change-me-in-dotenv JWT_EXPIRE_DAYS=30 +# ── SMTP (invite emails + future notifications) ─────────────────────────────── +SMTP_SERVER=linode.oneskyit.com +SMTP_PORT=465 +SMTP_USERNAME=send_mail +SMTP_PASSWORD= +SMTP_FROM_EMAIL=noreply@oneskyit.com +SMTP_FROM_NAME=Cortex +# Base URL included in invite links +CORTEX_BASE_URL=https://cortex.dgrzone.com + # ── Server ────────────────────────────────────────────────────────────────── HOST=0.0.0.0 PORT=8000 diff --git a/cortex/config.py b/cortex/config.py index f9c8b5c..9fb69b9 100644 --- a/cortex/config.py +++ b/cortex/config.py @@ -72,6 +72,16 @@ class Settings(BaseSettings): jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET= 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 diff --git a/cortex/email_utils.py b/cortex/email_utils.py new file mode 100644 index 0000000..fa72787 --- /dev/null +++ b/cortex/email_utils.py @@ -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"{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"""\ +

You've been invited to Cortex.

+

Click the link below to set your password and create your persona:

+

Set up my account →

+

+ Or copy this link:
+ {url} +

+

+ This link expires in 72 hours and can only be used once. +

+""" + + 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, + ) diff --git a/cortex/manage_passwords.py b/cortex/manage_passwords.py index 7aa1773..548920a 100644 --- a/cortex/manage_passwords.py +++ b/cortex/manage_passwords.py @@ -3,13 +3,15 @@ Password and invite management for Cortex users. Usage: - python manage_passwords.py set # prompt for password - python manage_passwords.py set # set directly (avoid in shell history) - python manage_passwords.py check # test a password interactively - python manage_passwords.py list # show which users have a password set - python manage_passwords.py invite # generate a one-time setup link + python manage_passwords.py set # prompt for password + python manage_passwords.py set # set directly (avoid in shell history) + python manage_passwords.py check # test a password interactively + python manage_passwords.py list # show users, passwords, and emails + python manage_passwords.py invite [email] # generate + optionally email invite link + python manage_passwords.py 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 [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 ") + 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 ") + print("Usage: manage_passwords.py invite [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 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: