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:
10
.env.default
10
.env.default
@@ -18,6 +18,16 @@ USER_NAME=Scott
|
|||||||
JWT_SECRET=change-me-in-dotenv
|
JWT_SECRET=change-me-in-dotenv
|
||||||
JWT_EXPIRE_DAYS=30
|
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 ──────────────────────────────────────────────────────────────────
|
# ── Server ──────────────────────────────────────────────────────────────────
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ class Settings(BaseSettings):
|
|||||||
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
|
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
|
||||||
jwt_expire_days: int = 30
|
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"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
|
||||||
|
|||||||
107
cortex/email_utils.py
Normal file
107
cortex/email_utils.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
Password and invite management for Cortex users.
|
Password and invite management for Cortex users.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python manage_passwords.py set <username> # prompt for password
|
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 set <username> <pass> # set directly (avoid in shell history)
|
||||||
python manage_passwords.py check <username> # test a password interactively
|
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 list # show users, passwords, and emails
|
||||||
python manage_passwords.py invite <username> # generate a one-time setup link
|
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 sys
|
||||||
import getpass
|
import getpass
|
||||||
|
|
||||||
@@ -21,6 +23,44 @@ from persona import list_users
|
|||||||
from config import settings
|
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):
|
def cmd_set(args):
|
||||||
if not args:
|
if not args:
|
||||||
print("Usage: manage_passwords.py set <username> [password]")
|
print("Usage: manage_passwords.py set <username> [password]")
|
||||||
@@ -52,29 +92,61 @@ def cmd_check(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_list(_args):
|
def cmd_list(_args):
|
||||||
for user in list_users():
|
users = list_users()
|
||||||
has = _auth_path(user).exists()
|
if not users:
|
||||||
status = "✓ password set" if has else "✗ no password"
|
print(" No users found in home/")
|
||||||
print(f" {user:<20} {status}")
|
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):
|
def cmd_invite(args):
|
||||||
if not args:
|
if not args:
|
||||||
print("Usage: manage_passwords.py invite <username>")
|
print("Usage: manage_passwords.py invite <username> [email]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
username = args[0]
|
|
||||||
|
|
||||||
# Create the user directory if it doesn't exist yet
|
username = args[0]
|
||||||
user_dir = settings.home_root() / username
|
email_arg = args[1] if len(args) >= 2 else None
|
||||||
user_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
# 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)
|
token = create_invite(username)
|
||||||
# Try to read host from settings for a helpful URL
|
url = f"{settings.cortex_base_url}/setup/{token}"
|
||||||
host = "cortex.dgrzone.com"
|
|
||||||
print(f"\nInvite link for {username!r}:")
|
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("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__":
|
if __name__ == "__main__":
|
||||||
@@ -91,6 +163,8 @@ if __name__ == "__main__":
|
|||||||
cmd_check(rest)
|
cmd_check(rest)
|
||||||
elif command == "list":
|
elif command == "list":
|
||||||
cmd_list(rest)
|
cmd_list(rest)
|
||||||
|
elif command == "email":
|
||||||
|
cmd_email(rest)
|
||||||
elif command == "invite":
|
elif command == "invite":
|
||||||
cmd_invite(rest)
|
cmd_invite(rest)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user