Files
Cortex-Inara/cortex/email_utils.py
Scott Idem 69f38ca7dc 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>
2026-03-20 23:19:09 -04:00

108 lines
2.9 KiB
Python

"""
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,
)