Admin user management, password reset by email, and the Ultimo Email name
Branding: the page titles and app name are now "Ultimo Email". Admin panel (now two columns with a divider): - Users: search by login or by a mail account's address or name (results only, nothing listed until something is typed). A user opens a window to change their quota, block/unblock them, or delete them; block and delete ask for confirmation, and an admin cannot do either to their own account. - Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear message and revokes every session; a new user.signed_out event sends the user's open tabs to the sign-in screen at once. Any "not signed in" answer now does the same, not only /auth/me. Deleting removes mail accounts (sync stops), plugin rows (cleared inside the user's RLS context, since plugin tables have no FK to users), blobs, the search index, sessions and the user. - Outgoing system mail (migration 0020): SMTP server, port, security, login, password (vault-encrypted, never sent back), From address and the public address reset links point to, plus a "Send test" that reports the server's error. Password reset: - "Forgot password?" on the sign-in page asks for the user name and always answers "If this account exists you will receive an email to your recovery email address"; the mail goes out after the response, so neither the reply nor its timing reveals an account, and it is capped at 3 per hour. - The emailed link (token in the URL fragment, single use, 30 minutes) opens a page to set a new password. With 2-step verification on, the code is checked before anything changes; five wrong codes burn the link. - Settings > Profile: the recovery email can be added, changed or removed, confirmed with the current password, which must be typed — autofill and paste are refused (TypedPasswordInput). Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the logo for a BIMI record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
|
||||
|
||||
Today that is only the password-reset link. It goes out through the SMTP
|
||||
account the administrator configured in the admin panel (``app_settings``),
|
||||
never through a user's own mail account — the whole point is that the user
|
||||
cannot sign in. The administrator's server is trusted, so unlike user mail
|
||||
accounts this connection is not held to the SSRF guard (it may well be a relay
|
||||
on the LAN).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_lib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, make_msgid
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENDER_NAME = "Ultimo Email"
|
||||
_TIMEOUT = 20.0
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""System mail is not configured, or the SMTP server refused the message."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MailerConfig:
|
||||
host: str
|
||||
port: int
|
||||
security: str # starttls | ssl | none
|
||||
username: str
|
||||
password: str
|
||||
sender: str
|
||||
public_url: str
|
||||
|
||||
|
||||
def mailer_config(row: AppSettings) -> MailerConfig | None:
|
||||
"""The usable configuration, or None while the admin has not set one up."""
|
||||
if not (row.smtp_host and row.smtp_from and row.public_url):
|
||||
return None
|
||||
password = decrypt_secret(row.smtp_secret_ct).decode() if row.smtp_secret_ct else ""
|
||||
return MailerConfig(
|
||||
host=row.smtp_host,
|
||||
port=row.smtp_port,
|
||||
security=row.smtp_security,
|
||||
username=row.smtp_username,
|
||||
password=password,
|
||||
sender=row.smtp_from,
|
||||
public_url=row.public_url.rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((SENDER_NAME, cfg.sender))
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
|
||||
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
|
||||
msg.set_content(text)
|
||||
msg.add_alternative(html, subtype="html")
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=cfg.host,
|
||||
port=cfg.port,
|
||||
username=cfg.username or None,
|
||||
password=cfg.password or None,
|
||||
use_tls=cfg.security == "ssl",
|
||||
start_tls=cfg.security == "starttls",
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
except (aiosmtplib.SMTPException, OSError) as exc:
|
||||
raise MailerError(str(exc) or type(exc).__name__) from exc
|
||||
|
||||
|
||||
def reset_mail(link: str, handle: str) -> tuple[str, str, str]:
|
||||
"""(subject, text, html) of the password-reset message."""
|
||||
subject = "Reset your Ultimo Email password"
|
||||
text = (
|
||||
f"Someone asked to change the password of the Ultimo Email account {handle}.\n\n"
|
||||
f"To choose a new password, open this link within 30 minutes:\n\n{link}\n\n"
|
||||
"The link works once. If you did not ask for this, ignore this email — "
|
||||
"your password stays as it is.\n"
|
||||
)
|
||||
h, u = html_lib.escape(handle), html_lib.escape(link, quote=True)
|
||||
muted = "font-size:13px;color:#6b7280"
|
||||
button = (
|
||||
"display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
|
||||
"padding:10px 18px;border-radius:8px;font-weight:bold"
|
||||
)
|
||||
body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
|
||||
html = (
|
||||
f'<!doctype html>\n<html><body style="{body}">\n'
|
||||
f"<p>Someone asked to change the password of the Ultimo Email account <b>{h}</b>.</p>\n"
|
||||
"<p>To choose a new password, open this link within 30 minutes:</p>\n"
|
||||
f'<p><a href="{u}" style="{button}">Change password</a></p>\n'
|
||||
f'<p style="{muted}">Or paste this address into your browser:<br>{u}</p>\n'
|
||||
f'<p style="{muted}">The link works once. If you did not ask for this, ignore this '
|
||||
"email — your password stays as it is.</p>\n</body></html>\n"
|
||||
)
|
||||
return subject, text, html
|
||||
Reference in New Issue
Block a user