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>
111 lines
4.6 KiB
Python
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("&", "&").replace("<", "<") \
|
|
.replace(">", ">").replace('"', """)
|