Private
Public Access
Add admin-invited signups and email notifications (Gitea issue #15)
Site admins can invite a brand-new person by email from the Admin portal Users tab -- a signup-link email lets them set their own username/password and lands them in the app already logged in. Existing users invited to a room now also get an email. Closes the "invited but never notified" gap from both directions. SMTP is configured through the Admin Settings tab at runtime (not the env file), persisted in a new smtp_settings table with the password encrypted at rest via a Fernet key derived from SESSION_SECRET -- the first reversible secret this app stores in the database. A "send test email" button surfaces real delivery errors; the invite/notification paths themselves never fail loudly, since an SMTP outage shouldn't block an action that already succeeded in the database. New site_invites table mirrors RoomInvite's shape but targets an email address with no room context; the raw signup token is hashed the same way API tokens are, and only ever exists in the email link. POST /api/signup is the first genuinely public, unauthenticated account-creation endpoint in this app, reusing the existing register_user path for identical validation.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crypto import decrypt
|
||||
from app.models import SmtpSettings
|
||||
from app.services.smtp_settings_service import get_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmtpNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
|
||||
"""Raises on failure -- internal helper only. Callers decide whether to
|
||||
swallow (send_email) or surface (send_test_email) the error."""
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg.from_address
|
||||
message["To"] = to_address
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
|
||||
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=cfg.host,
|
||||
port=cfg.port,
|
||||
username=cfg.username or None,
|
||||
password=password,
|
||||
use_tls=cfg.use_tls,
|
||||
)
|
||||
|
||||
|
||||
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
|
||||
"""Best-effort -- used by invite/notification flows. Never raises: an
|
||||
SMTP outage or missing configuration must never block an action (an
|
||||
invite, a room membership) that already succeeded in the database."""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
logger.debug("SMTP not configured; skipping email to %s", to_address)
|
||||
return
|
||||
try:
|
||||
await _deliver(cfg, to_address, subject, body)
|
||||
except Exception:
|
||||
logger.warning("Failed to send email to %s", to_address, exc_info=True)
|
||||
|
||||
|
||||
async def send_test_email(db: AsyncSession, to_address: str) -> None:
|
||||
"""Used only by the admin 'send test email' button -- raises so the
|
||||
admin UI can show why it failed instead of a silent no-op."""
|
||||
cfg = await get_smtp_settings(db)
|
||||
if cfg is None:
|
||||
raise SmtpNotConfiguredError()
|
||||
await _deliver(
|
||||
cfg,
|
||||
to_address,
|
||||
"KeepItTalking test email",
|
||||
"This is a test email from KeepItTalking to confirm your SMTP settings are working.",
|
||||
)
|
||||
@@ -6,7 +6,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import InviteStatus, RoomInvite, RoomMembership, RoomRole, User
|
||||
from app.models import InviteStatus, Room, RoomInvite, RoomMembership, RoomRole, User
|
||||
from app.services.email_service import send_email
|
||||
|
||||
|
||||
class TargetUserNotFoundError(Exception):
|
||||
@@ -38,7 +39,11 @@ class InviteExpiredError(Exception):
|
||||
|
||||
|
||||
async def create_invite(
|
||||
db: AsyncSession, room_id: uuid.UUID, invited_by: uuid.UUID, target_username: str
|
||||
db: AsyncSession,
|
||||
room_id: uuid.UUID,
|
||||
invited_by: uuid.UUID,
|
||||
target_username: str,
|
||||
base_url: str,
|
||||
) -> RoomInvite:
|
||||
result = await db.execute(select(User).where(User.username == target_username))
|
||||
target = result.scalar_one_or_none()
|
||||
@@ -73,6 +78,15 @@ async def create_invite(
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
invite.target_user = target
|
||||
|
||||
room = await db.get(Room, room_id)
|
||||
await send_email(
|
||||
db,
|
||||
target.email,
|
||||
f"You've been invited to #{room.name}" if room else "You've been invited to a room",
|
||||
f"You've been invited to join a room on KeepItTalking.\n\n"
|
||||
f"Open the app to accept: {base_url.rstrip('/')}",
|
||||
)
|
||||
return invite
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import InviteStatus, SiteInvite, User
|
||||
from app.schemas.user import UserCreate
|
||||
from app.security import hash_token
|
||||
from app.services.audit import record_audit_log
|
||||
from app.services.auth_service import register_user
|
||||
from app.services.email_service import send_email
|
||||
|
||||
|
||||
class SiteInviteNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteInviteNotPendingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteInviteInvalidError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_site_invite(
|
||||
db: AsyncSession, actor: User, base_url: str, email: str
|
||||
) -> SiteInvite:
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
invite_id = uuid.uuid4()
|
||||
invite = SiteInvite(
|
||||
id=invite_id, email=email, invited_by=actor.id, token_hash=hash_token(raw_token)
|
||||
)
|
||||
db.add(invite)
|
||||
record_audit_log(db, actor, "user.invite", "invite", invite_id, {"email": email})
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}"
|
||||
await send_email(
|
||||
db,
|
||||
email,
|
||||
"You're invited to join KeepItTalking",
|
||||
f"You've been invited to join KeepItTalking by {actor.username}.\n\n"
|
||||
f"Set up your account here:\n{signup_link}\n\n"
|
||||
f"This link expires in 7 days.",
|
||||
)
|
||||
return invite
|
||||
|
||||
|
||||
async def list_site_invites(db: AsyncSession) -> list[SiteInvite]:
|
||||
result = await db.execute(
|
||||
select(SiteInvite)
|
||||
.options(selectinload(SiteInvite.inviter))
|
||||
.order_by(SiteInvite.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID) -> SiteInvite:
|
||||
invite = await db.get(SiteInvite, invite_id)
|
||||
if invite is None:
|
||||
raise SiteInviteNotFoundError()
|
||||
if invite.status != InviteStatus.pending:
|
||||
raise SiteInviteNotPendingError()
|
||||
|
||||
invite.status = InviteStatus.revoked
|
||||
record_audit_log(db, actor, "invite.revoke", "invite", invite.id)
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
return invite
|
||||
|
||||
|
||||
async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite:
|
||||
result = await db.execute(
|
||||
select(SiteInvite).where(SiteInvite.token_hash == hash_token(token))
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if invite is None or invite.status != InviteStatus.pending:
|
||||
raise SiteInviteInvalidError()
|
||||
if invite.expires_at <= datetime.now(timezone.utc):
|
||||
raise SiteInviteInvalidError()
|
||||
return invite
|
||||
|
||||
|
||||
async def validate_signup_token(db: AsyncSession, token: str) -> SiteInvite:
|
||||
return await _get_pending_invite_by_token(db, token)
|
||||
|
||||
|
||||
async def complete_signup(db: AsyncSession, token: str, username: str, password: str) -> User:
|
||||
invite = await _get_pending_invite_by_token(db, token)
|
||||
|
||||
user = await register_user(
|
||||
db, UserCreate(username=username, email=invite.email, password=password)
|
||||
)
|
||||
|
||||
invite.status = InviteStatus.accepted
|
||||
await db.commit()
|
||||
return user
|
||||
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crypto import encrypt
|
||||
from app.models import SmtpSettings
|
||||
|
||||
|
||||
async def get_smtp_settings(db: AsyncSession) -> SmtpSettings | None:
|
||||
result = await db.execute(select(SmtpSettings).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_smtp_settings(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
from_address: str,
|
||||
use_tls: bool,
|
||||
) -> SmtpSettings:
|
||||
settings_row = await get_smtp_settings(db)
|
||||
if settings_row is None:
|
||||
settings_row = SmtpSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
from_address=from_address,
|
||||
use_tls=use_tls,
|
||||
)
|
||||
db.add(settings_row)
|
||||
else:
|
||||
settings_row.host = host
|
||||
settings_row.port = port
|
||||
settings_row.username = username
|
||||
settings_row.from_address = from_address
|
||||
settings_row.use_tls = use_tls
|
||||
|
||||
# A blank password in the request means "keep the current one" -- the
|
||||
# frontend never has the plaintext to send back, only whether one is
|
||||
# already set (SmtpSettingsRead.has_password).
|
||||
if password:
|
||||
settings_row.password_encrypted = encrypt(password)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings_row)
|
||||
return settings_row
|
||||
Reference in New Issue
Block a user