Files
ai-agent/backend/users/mailer.py
Gabriel Vidal 38fc376a3a feat(users): owner-sent links are 3-day invitations with an invite mail
A link the owner copies or mails from Settings → Users is read whenever
the guest gets to their inbox, so it now lives INVITE_LINK_TTL_S (3 days,
still single-use) instead of the login page's 15 minutes, and the mail
says who invited them to which projects and where to sign in next time.
The cooldown on self-requested links reads each link's creation time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 17:16:06 +02:00

111 lines
4.6 KiB
Python

"""
Outbound mail for the built-in login (and guest notifications): plain SMTP
submission, configured by env so the standalone container can point it at any
server.
* ``SMTP_HOST`` / ``SMTP_PORT`` (587) — unset host ⇒ mail is off: login links
can still be copied from Settings → Users.
* ``SMTP_USER`` / ``SMTP_PASS`` — submission login (skipped when empty).
* ``SMTP_FROM`` — the sender (``claude@gabvdl.xyz`` in the homelab, where the
submission endpoint is Maddy's ``mail:587`` on the shared docker network and
it DKIM-signs what it relays).
* ``SMTP_STARTTLS`` — ``1`` to upgrade the connection (off on a private
network, as the mail CLI does).
"""
from __future__ import annotations
import email.message
import email.utils
import logging
import os
import smtplib
log = logging.getLogger("ai-agent.mail")
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587") or 587)
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASS = os.environ.get("SMTP_PASS", "")
SMTP_FROM = os.environ.get("SMTP_FROM", "")
SMTP_STARTTLS = os.environ.get("SMTP_STARTTLS", "0") == "1"
APP_NAME = os.environ.get("APP_NAME", "ai-agent")
def enabled() -> bool:
return bool(SMTP_HOST and SMTP_FROM)
class MailError(RuntimeError):
pass
def send(to: str, subject: str, text: str, html: str | None = None) -> None:
if not enabled():
raise MailError("mail is not configured (SMTP_HOST / SMTP_FROM)")
msg = email.message.EmailMessage()
msg["From"] = email.utils.formataddr((APP_NAME, SMTP_FROM))
msg["To"] = to
msg["Subject"] = subject
msg["Date"] = email.utils.formatdate(localtime=True)
msg["Message-ID"] = email.utils.make_msgid(domain=SMTP_FROM.split("@")[-1])
msg["Auto-Submitted"] = "auto-generated"
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.ehlo()
if SMTP_STARTTLS:
s.starttls()
s.ehlo()
if SMTP_USER and SMTP_PASS:
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg, from_addr=SMTP_FROM, to_addrs=[to])
except (smtplib.SMTPException, OSError) as exc:
log.warning("mail to %s failed: %s", to, exc)
raise MailError(f"sending failed: {exc}") from exc
def login_mail(to: str, name: str, url: str, minutes: int) -> None:
text = (f"Hi {name},\n\n"
f"Here is your sign-in link for {APP_NAME}:\n\n{url}\n\n"
f"It works once and expires in {minutes} minutes. If you didn't "
f"ask for it, ignore this mail.\n")
html = (f"<p>Hi {_esc(name)},</p>"
f"<p><a href=\"{_esc(url)}\" style=\"display:inline-block;padding:10px 18px;"
f"border-radius:8px;background:#6d5efc;color:#fff;text-decoration:none\">"
f"Sign in to {_esc(APP_NAME)}</a></p>"
f"<p style=\"color:#666\">The link works once and expires in {minutes} "
f"minutes. If you didn't ask for it, ignore this mail.</p>")
send(to, f"Sign in to {APP_NAME}", text, html)
def invite_mail(to: str, name: str, url: str, *, inviter: str,
projects: list[str], expires: str, home: str | None) -> None:
"""The owner's invitation: who invited them, to what, the one-time link,
and how to sign in again later (the guest host's login page)."""
proj = ", ".join(projects) if projects else "a project"
again = (f"Later on, sign in at {home} with this address — a fresh link "
f"is mailed to you each time.") if home else ""
text = (f"Hi {name},\n\n"
f"{inviter} invited you to work with Claude on {proj}.\n\n"
f"Sign in here (the link works once and expires in {expires}):\n{url}\n\n"
f"{again}\n")
html = (f"<p>Hi {_esc(name)},</p>"
f"<p>{_esc(inviter)} invited you to work with Claude on "
f"<b>{_esc(proj)}</b>.</p>"
f"<p><a href=\"{_esc(url)}\" style=\"display:inline-block;padding:10px 18px;"
f"border-radius:8px;background:#f97316;color:#fff;text-decoration:none\">"
f"Accept and sign in</a></p>"
f"<p style=\"color:#666\">The link works once and expires in {_esc(expires)}."
+ (f" Later on, sign in at <a href=\"{_esc(home)}\">{_esc(home)}</a> with "
f"this address — a fresh link is mailed to you each time." if home else "")
+ "</p>")
send(to, f"{inviter} invited you to {APP_NAME}", text, html)
def _esc(s: str) -> str:
return (s or "").replace("&", "&amp;").replace("<", "&lt;") \
.replace(">", "&gt;").replace('"', "&quot;")