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:
2026-08-14 17:38:56 -06:00
parent ad1beccd3a
commit b724f8a33b
28 changed files with 1561 additions and 20 deletions
+66 -9
View File
@@ -1,4 +1,4 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles)
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a
@@ -7,9 +7,10 @@ via Redis pub/sub, Web Push notifications for offline room members, a
site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and
outgoing webhooks, message editing), image uploads in chat messages, emoji
reactions on messages, and self-service user profiles (display name,
avatar). See `../ARCHITECTURE.md` for the full system design and the
phased build plan.
reactions on messages, self-service user profiles (display name, avatar),
and admin-issued email invites for new accounts plus email notifications
for room invites. See `../ARCHITECTURE.md` for the full system design and
the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below.
@@ -117,18 +118,22 @@ app/
require_room_member, require_room_role,
require_site_admin, require_scope
security.py argon2 password hashing + token generate/hash (sha256)
crypto.py Fernet encrypt/decrypt keyed from SESSION_SECRET --
the only reversible secret this app stores in
the database (SMTP password), see Site invites
& email below
storage.py uploaded-image validation (Pillow), downscaling
(optionally square-cropped, for avatars), and
on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions,
room_invites, push_subscriptions,
admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
room_invites, site_invites, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, users, invites, push, admin, bots,
webhooks, health
routers/ auth, rooms, users, invites, signup, push, admin,
bots, webhooks, health
services/ business logic called by routers
ws/ connection_manager (local sockets), presence +
broadcaster (Redis), /ws/chat handler
@@ -415,6 +420,58 @@ resolves both live from the room's member list instead of freezing them
per-message, which is the more correct behavior for a field the sender can
change after the fact.
## Site invites & email
Two related gaps closed together: creating a new account was CLI-only, and
neither a brand-new invitee nor an existing user invited to a room got any
notification. Site admins (only) invite a brand-new person by email from
the Admin portal; both that signup-invite and the existing room-invite flow
send an email.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by
invite flows — if `SmtpSettings` isn't configured yet it logs at debug and
returns (same "silently skip if unconfigured" UX push notifications already
use for a missing VAPID key), and it never raises on delivery failure (an
SMTP outage must not block an invite/membership action that already
succeeded in the database). `send_test_email(db, to)` is the one exception —
used only by the admin "send test email" button, it raises so the UI can
show *why* it failed instead of a silent no-op. Plain-text bodies only, no
HTML templates, matching this codebase's existing minimalism.
**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s
`/settings/smtp` endpoints) lives in the database, not the env file — the
Admin Settings tab edits it at runtime with no redeploy. It's the first
reversible secret this app stores in the database (`password_hash` is
one-way, API tokens are looked up by hash and never decrypted), so it's
encrypted at rest via `app/crypto.py`: a Fernet key derived from the
already-required `SESSION_SECRET` rather than a new env var. A blank
password on update means "keep the current one" — the frontend never has
the plaintext to send back, only whether one is set (`has_password`).
**Site invites** (`app/models/site_invite.py`, `app/services/site_invite_service.py`) —
distinct from `RoomInvite` (existing user, specific room): this targets an
email address for the site, no room involved. The raw token exists only in
the email link, stored hashed (`security.hash_token`, the same convention
API tokens use — it's a bearer secret looked up by itself, not
`RoomInvite.token`'s current unhashed/unused column). `POST /api/signup`
(`app/routers/signup.py`) is the first genuinely public,
unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical
hashing/uniqueness handling, and logs the new user in immediately (same
session-cookie line `auth.py`'s `login()` uses) so they land in the app
already signed in. No new rate limiting on it — the unguessable, single-use,
expiring token is the actual protection, inheriting the same "no rate
limiting on human/bot traffic" gap already documented below, not a new one.
**Room-invite email**: `invite_service.create_invite` sends one email to
the target user after creating the `RoomInvite`, using the live request's
`base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers
it), no HTML email templates.
## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
@@ -0,0 +1,59 @@
"""site invites and smtp settings
Revision ID: 41139ce908df
Revises: f6e024985d4d
Create Date: 2026-08-14 17:23:11.263283
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '41139ce908df'
down_revision: Union[str, Sequence[str], None] = 'f6e024985d4d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('smtp_settings',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('host', sa.String(length=255), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('password_encrypted', sa.Text(), nullable=True),
sa.Column('from_address', sa.String(length=255), nullable=False),
sa.Column('use_tls', sa.Boolean(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('site_invites',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('invited_by', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('status', postgresql.ENUM('pending', 'accepted', 'revoked', name='invite_status', create_type=False), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_site_invites_email'), 'site_invites', ['email'], unique=False)
op.create_index(op.f('ix_site_invites_token_hash'), 'site_invites', ['token_hash'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_site_invites_token_hash'), table_name='site_invites')
op.drop_index(op.f('ix_site_invites_email'), table_name='site_invites')
op.drop_table('site_invites')
op.drop_table('smtp_settings')
# ### end Alembic commands ###
+23
View File
@@ -0,0 +1,23 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from app.config import settings
def _fernet() -> Fernet:
# Derives a stable Fernet key from SESSION_SECRET rather than requiring
# a new env var -- this is the only reversible secret this app stores
# in the database (SMTP password), so it gets real encryption at rest,
# but doesn't need its own deployment configuration to do it.
key = hashlib.sha256(settings.session_secret.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt(plaintext: str) -> str:
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
return _fernet().decrypt(ciphertext.encode()).decode()
+2 -1
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import admin, auth, bots, health, invites, push, rooms, users, webhooks
from app.routers import admin, auth, bots, health, invites, push, rooms, signup, users, webhooks
from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -72,6 +72,7 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(signup.router)
app.include_router(rooms.router)
app.include_router(users.router)
app.include_router(invites.router)
+4
View File
@@ -9,6 +9,8 @@ from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings
from app.models.user import User
from app.models.webhook_incoming import WebhookIncoming
@@ -23,6 +25,8 @@ __all__ = [
"MessageReaction",
"RoomInvite",
"InviteStatus",
"SiteInvite",
"SmtpSettings",
"PushSubscription",
"AdminAuditLog",
"ApiToken",
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.models.invite import InviteStatus
DEFAULT_SITE_INVITE_LIFETIME = timedelta(days=7)
def _default_expires_at() -> datetime:
return datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME
class SiteInvite(Base):
"""An admin-issued invite for someone with no account yet -- distinct
from RoomInvite, which targets an existing user for a specific room."""
__tablename__ = "site_invites"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
# The raw token only ever exists in the invite email link -- like an API
# token, it's a bearer secret looked up by itself, so it's stored hashed
# (app.security.hash_token), not in plaintext.
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_default_expires_at, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
inviter = relationship("User")
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class SmtpSettings(Base):
"""A single-row table (enforced in the service layer, not the schema --
there's no clean single-row DB constraint) holding the site's SMTP
configuration, set through the Admin UI at runtime rather than the env
file."""
__tablename__ = "smtp_settings"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False)
username: Mapped[str | None] = mapped_column(String(255))
# Encrypted at rest (app.crypto) with a key derived from SESSION_SECRET
# -- the only reversible secret this app stores in the database, unlike
# password_hash (one-way) or api_tokens (looked up by hash, never
# decrypted).
password_encrypted: Mapped[str | None] = mapped_column(Text)
from_address: Mapped[str] = mapped_column(String(255), nullable=False)
use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
+108 -1
View File
@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +14,8 @@ from app.schemas.admin import (
ResetPasswordRequest,
TransferOwnershipRequest,
)
from app.schemas.site_invite import SiteInviteCreate, SiteInviteRead
from app.schemas.smtp_settings import SmtpSettingsRead, SmtpSettingsUpdate
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
from app.services.admin_service import (
CannotActOnSelfError,
@@ -29,6 +31,15 @@ from app.services.admin_service import (
transfer_ownership_admin,
)
from app.services.audit import list_audit_log
from app.services.email_service import SmtpNotConfiguredError, send_test_email
from app.services.site_invite_service import (
SiteInviteNotFoundError,
SiteInviteNotPendingError,
create_site_invite,
list_site_invites,
revoke_site_invite,
)
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
from app.services.webhook_service import (
list_all_event_subscriptions_admin,
list_all_incoming_webhooks_admin,
@@ -272,3 +283,99 @@ async def list_event_subscriptions_admin_endpoint(
)
for s in subscriptions
]
@router.post("/invites", response_model=SiteInviteRead, status_code=201)
async def create_site_invite_endpoint(
data: SiteInviteCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
return await create_site_invite(db, current_user, str(request.base_url), data.email)
@router.get("/invites", response_model=list[SiteInviteRead])
async def list_site_invites_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
return await list_site_invites(db)
@router.delete("/invites/{invite_id}", response_model=SiteInviteRead)
async def revoke_site_invite_endpoint(
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await revoke_site_invite(db, current_user, invite_id)
except SiteInviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except SiteInviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
async def get_smtp_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await get_smtp_settings(db)
if cfg is None:
return None
return SmtpSettingsRead(
host=cfg.host,
port=cfg.port,
username=cfg.username,
has_password=bool(cfg.password_encrypted),
from_address=cfg.from_address,
use_tls=cfg.use_tls,
updated_at=cfg.updated_at,
)
@router.put("/settings/smtp", response_model=SmtpSettingsRead)
async def update_smtp_settings_endpoint(
data: SmtpSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await upsert_smtp_settings(
db,
host=data.host,
port=data.port,
username=data.username,
password=data.password,
from_address=data.from_address,
use_tls=data.use_tls,
)
return SmtpSettingsRead(
host=cfg.host,
port=cfg.port,
username=cfg.username,
has_password=bool(cfg.password_encrypted),
from_address=cfg.from_address,
use_tls=cfg.use_tls,
updated_at=cfg.updated_at,
)
@router.post("/settings/smtp/test", status_code=204)
async def test_smtp_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
await send_test_email(db, current_user.email)
except SmtpNotConfiguredError:
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to send test email: {exc}")
+4 -1
View File
@@ -390,12 +390,15 @@ def _to_invite_read(invite) -> InviteRead:
async def create_invite_endpoint(
room_id: uuid.UUID,
data: InviteCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
invite = await create_invite(db, room_id, current_user.id, data.target_username)
invite = await create_invite(
db, room_id, current_user.id, data.target_username, str(request.base_url)
)
except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that username")
except AlreadyMemberError:
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError
from app.services.site_invite_service import (
SiteInviteInvalidError,
complete_signup,
validate_signup_token,
)
router = APIRouter(prefix="/api/signup", tags=["signup"])
@router.get("/validate", response_model=SignupValidateRead)
async def validate_signup_endpoint(
token: str = Query(...),
db: AsyncSession = Depends(get_db),
):
try:
invite = await validate_signup_token(db, token)
except SiteInviteInvalidError:
raise HTTPException(status_code=400, detail="This invite link is invalid or has expired")
return SignupValidateRead(email=invite.email)
@router.post("", response_model=UserRead)
async def complete_signup_endpoint(
data: SignupComplete,
request: Request,
db: AsyncSession = Depends(get_db),
):
try:
user = await complete_signup(db, data.token, data.username, data.password)
except SiteInviteInvalidError:
raise HTTPException(status_code=400, detail="This invite link is invalid or has expired")
except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken")
request.session["user_id"] = str(user.id)
return user
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.models import InviteStatus
class SiteInviteCreate(BaseModel):
email: EmailStr
class SiteInviteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
invited_by: uuid.UUID
status: InviteStatus
expires_at: datetime
created_at: datetime
class SignupValidateRead(BaseModel):
email: str
class SignupComplete(BaseModel):
token: str
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=200)
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class SmtpSettingsUpdate(BaseModel):
host: str = Field(min_length=1, max_length=255)
port: int = Field(ge=1, le=65535)
username: str | None = None
# None/omitted = keep the existing password unchanged -- the frontend
# never has the plaintext to send back, only whether one is set.
password: str | None = None
from_address: EmailStr
use_tls: bool = True
class SmtpSettingsRead(BaseModel):
host: str
port: int
username: str | None
has_password: bool
from_address: str
use_tls: bool
updated_at: datetime
+63
View File
@@ -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.",
)
+16 -2
View File
@@ -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
+102
View File
@@ -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
+2
View File
@@ -20,6 +20,8 @@ dependencies = [
"gunicorn>=23.0",
"Pillow>=10.0",
"python-multipart>=0.0.9",
"aiosmtplib>=3.0",
"cryptography>=43.0",
]
[project.scripts]
+61 -1
View File
@@ -1,10 +1,28 @@
import uuid
from datetime import datetime, timedelta, timezone
from app.models import RoomInvite
from app.models import RoomInvite, User
from tests.conftest import login_as, register_and_login
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def _configure_smtp(client):
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"from_address": "noreply@example.com",
},
)
assert resp.status_code == 200, resp.text
async def _create_private_room(client, name="secret"):
resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
assert resp.status_code == 201, resp.text
@@ -171,3 +189,45 @@ async def test_expired_invite_rejected_on_accept(client, db_session):
await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400
async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append(kwargs)
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
alice = await register_and_login(client, db_session, username="alice")
await _make_admin(db_session, alice["id"])
await _configure_smtp(client)
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
assert len(calls) == 1
assert calls[0]["hostname"] == "smtp.example.com"
async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch):
async def fake_send(message, **kwargs):
raise ConnectionRefusedError("boom")
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
alice = await register_and_login(client, db_session, username="alice")
await _make_admin(db_session, alice["id"])
await _configure_smtp(client)
room = await _create_private_room(client)
await register_and_login(client, db_session, username="bob")
await login_as(client, "alice")
resp = await client.post(
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
)
assert resp.status_code == 201
+170
View File
@@ -0,0 +1,170 @@
import re
import uuid
from datetime import datetime, timedelta, timezone
from app.models import SiteInvite, User
from tests.conftest import login_as, register_and_login
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
def _fake_smtp(monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append({"message": message, **kwargs})
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
return calls
async def _configure_smtp(client):
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"username": "bot",
"password": "secret",
"from_address": "noreply@example.com",
"use_tls": True,
},
)
assert resp.status_code == 200, resp.text
def _extract_token(body: str) -> str:
match = re.search(r"token=([^\s&]+)", body)
assert match, f"no token found in email body: {body}"
return match.group(1)
async def test_create_site_invite_requires_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
assert resp.status_code == 403
async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
assert resp.status_code == 201, resp.text
invite = resp.json()
assert invite["email"] == "newperson@example.com"
assert invite["status"] == "pending"
assert len(calls) == 1
token = _extract_token(calls[0]["message"].get_content())
validate = await client.get(f"/api/signup/validate?token={token}")
assert validate.status_code == 200
assert validate.json()["email"] == "newperson@example.com"
complete = await client.post(
"/api/signup",
json={"token": token, "username": "newperson", "password": "password123"},
)
assert complete.status_code == 200, complete.text
assert complete.json()["email"] == "newperson@example.com"
me = await client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["username"] == "newperson"
async def test_invalid_token_rejected(client, db_session):
await register_and_login(client, db_session, username="alice")
validate = await client.get("/api/signup/validate?token=not-a-real-token")
assert validate.status_code == 400
complete = await client.post(
"/api/signup",
json={"token": "not-a-real-token", "username": "someone", "password": "password123"},
)
assert complete.status_code == 400
async def test_expired_token_rejected(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
invite_id = resp.json()["id"]
token = _extract_token(calls[0]["message"].get_content())
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
await db_session.commit()
complete = await client.post(
"/api/signup",
json={"token": token, "username": "late", "password": "password123"},
)
assert complete.status_code == 400
async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "once@example.com"})
token = _extract_token(calls[0]["message"].get_content())
first = await client.post(
"/api/signup",
json={"token": token, "username": "onceuser", "password": "password123"},
)
assert first.status_code == 200
second = await client.post(
"/api/signup",
json={"token": token, "username": "onceuser2", "password": "password123"},
)
assert second.status_code == 400
async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatch):
calls = _fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
invite_id = resp.json()["id"]
token = _extract_token(calls[0]["message"].get_content())
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
assert revoke.status_code == 200
assert revoke.json()["status"] == "revoked"
complete = await client.post(
"/api/signup",
json={"token": token, "username": "revokeduser", "password": "password123"},
)
assert complete.status_code == 400
async def test_list_site_invites(client, db_session, monkeypatch):
_fake_smtp(monkeypatch)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await _configure_smtp(client)
await client.post("/api/admin/invites", json={"email": "listed@example.com"})
resp = await client.get("/api/admin/invites")
assert resp.status_code == 200
emails = [i["email"] for i in resp.json()]
assert "listed@example.com" in emails
+174
View File
@@ -0,0 +1,174 @@
import uuid
from sqlalchemy import select
from app.crypto import decrypt
from app.models import SmtpSettings, User
from tests.conftest import register_and_login
async def _get_settings_row(db_session) -> SmtpSettings:
result = await db_session.execute(select(SmtpSettings))
return result.scalar_one()
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def test_smtp_settings_require_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.get("/api/admin/settings/smtp")
assert resp.status_code == 403
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"from_address": "noreply@example.com",
},
)
assert resp.status_code == 403
async def test_smtp_settings_get_before_configured(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.get("/api/admin/settings/smtp")
assert resp.status_code == 200
assert resp.json() is None
async def test_smtp_settings_update_and_password_never_returned(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"username": "bot",
"password": "super-secret",
"from_address": "noreply@example.com",
"use_tls": True,
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "password" not in body
assert body["has_password"] is True
assert body["host"] == "smtp.example.com"
get_resp = await client.get("/api/admin/settings/smtp")
assert get_resp.status_code == 200
assert "password" not in get_resp.json()
assert get_resp.json()["has_password"] is True
async def test_smtp_settings_password_encrypted_at_rest(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"password": "super-secret",
"from_address": "noreply@example.com",
},
)
row = await _get_settings_row(db_session)
assert row.password_encrypted != "super-secret"
assert decrypt(row.password_encrypted) == "super-secret"
async def test_smtp_settings_blank_password_keeps_existing(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"password": "first-password",
"from_address": "noreply@example.com",
},
)
encrypted_before = (await _get_settings_row(db_session)).password_encrypted
resp = await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 2525,
"from_address": "noreply@example.com",
},
)
assert resp.status_code == 200
assert resp.json()["port"] == 2525
assert resp.json()["has_password"] is True
row = await _get_settings_row(db_session)
assert row.password_encrypted == encrypted_before
async def test_send_test_email_not_configured(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post("/api/admin/settings/smtp/test")
assert resp.status_code == 400
async def test_send_test_email_success(client, db_session, monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append(kwargs)
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"from_address": "noreply@example.com",
},
)
resp = await client.post("/api/admin/settings/smtp/test")
assert resp.status_code == 204
assert len(calls) == 1
assert calls[0]["hostname"] == "smtp.example.com"
async def test_send_test_email_surfaces_failure(client, db_session, monkeypatch):
async def fake_send(message, **kwargs):
raise ConnectionRefusedError("boom")
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put(
"/api/admin/settings/smtp",
json={
"host": "smtp.example.com",
"port": 587,
"from_address": "noreply@example.com",
},
)
resp = await client.post("/api/admin/settings/smtp/test")
assert resp.status_code == 502