feat(auth): one mail, one click — the approval mail carries the confirmation link (E9.5 S6)
The funnel's common ordering made a person open TWO messages: one saying their access was approved, another asking them to confirm their address. The approval mail now branches on what they actually still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse than a plain one: telling someone to sign in while their address is unconfirmed contradicts the screen that will refuse them. That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval — it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in and sets no password. The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here too, and it was never a delivery failure: re-registering an address that already had an unconfirmed account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend already does exactly this for the same input. Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a possibility — usually already agreed with the person — and a row can sit approved for weeks with nobody registering; a mail fired automatically there would be nudging someone who never asked. Telling them is its own act on its own button, `invited_at` records it so a second click is a decision rather than an accident, and the button only appears where nobody is behind the address yet: "the door is open, come in" is meaningless to someone already inside. Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may sign in, and this account still can't", which leaves an undecided request with the queue that owns it. And the invitation button was offered on every approved row, half of which are existing users. Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail always has, while this one left the reader to discover both by meeting a dead link. The card's "didn't get the email?" moves up under the form, and the three waiting messages name the spam folder, which is where a first message to a fresh recipient sometimes lands. Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a preference nobody recorded cannot be backfilled later. UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed nobody; the invitation recorded itself and the row then warned that sending again would write twice. Test data removed.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""invites.invited_at — when the admin actually told this person the door was open
|
||||
|
||||
Revision ID: 0081_invite_invited_at
|
||||
Revises: 0080_google_email
|
||||
Create Date: 2026-09-05
|
||||
|
||||
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
|
||||
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
|
||||
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
|
||||
|
||||
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
|
||||
Without a record the only failure mode is writing to a real person twice, which is exactly the
|
||||
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
|
||||
the decision is made.
|
||||
|
||||
NULL means "never invited from here", which is true of every existing row: no backfill is possible
|
||||
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0081_invite_invited_at"
|
||||
down_revision: str | None = "0080_google_email"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "invited_at")
|
||||
@@ -35,6 +35,9 @@ class AuditAction:
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
INVITE_DELETE = "invite_delete"
|
||||
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
|
||||
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
||||
INVITE_SEND = "invite_send"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
|
||||
+13
-7
@@ -121,14 +121,20 @@ def count_pending_activations(db: Session) -> int:
|
||||
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
|
||||
mail may never have arrived.
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
User.is_active.is_(False),
|
||||
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
|
||||
# button the admin sees cannot drift apart by one of them re-deriving the rule.
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
User.is_active.is_(False),
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
return sum(1 for u in rows if is_allowed(db, u.email))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return sum(1 for u in rows if can_activate(db, u))
|
||||
|
||||
|
||||
def find_user_by_email(db: Session, email: str) -> User | None:
|
||||
|
||||
+63
-4
@@ -184,19 +184,74 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
def _next_step(verify_link: str | None, has_account: bool, hours: int) -> str:
|
||||
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
||||
|
||||
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
||||
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
||||
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
||||
|
||||
The link branch is the point of the C option: in the common ordering — approve first, the person
|
||||
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
||||
messages. One mail, one click.
|
||||
"""
|
||||
if verify_link:
|
||||
return (
|
||||
"You already started an account, so there is just one step left. Confirm your email "
|
||||
"address and you're in:\n\n"
|
||||
f"{verify_link}\n\n"
|
||||
f"The link works once and expires in {hours} hours. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page."
|
||||
)
|
||||
if has_account:
|
||||
return f"Your account is ready — just sign in:\n\n{settings.app_base}"
|
||||
return (
|
||||
"Create your account whenever you feel like it:\n\n"
|
||||
f"{settings.app_base}\n\n"
|
||||
"We'll send you a confirmation link to finish, and then you're in."
|
||||
)
|
||||
|
||||
|
||||
def send_access_approved(
|
||||
email: str, verify_link: str | None = None, has_account: bool = False, hours: int = 24
|
||||
) -> bool:
|
||||
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
||||
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
||||
f"{settings.app_base}\n\n"
|
||||
f"{_next_step(verify_link, has_account, hours)}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
)
|
||||
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
||||
|
||||
|
||||
def send_access_invitation(
|
||||
email: str, verify_link: str | None = None, has_account: bool = False, hours: int = 24
|
||||
) -> bool:
|
||||
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
||||
|
||||
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
||||
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
||||
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
||||
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
||||
"""
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||
"livestreams unless you want them.\n\n"
|
||||
f"{_next_step(verify_link, has_account, hours)}\n\n"
|
||||
"No rush — the door stays open.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
)
|
||||
return _send([email], "You're invited to Siftlode", body, reply_to=admin)
|
||||
|
||||
|
||||
def send_access_denied(email: str) -> bool:
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
@@ -277,11 +332,15 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||
|
||||
|
||||
def send_verify_email(to: str, link: str) -> bool:
|
||||
def send_verify_email(to: str, link: str, hours: int = 24) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
f"{link}\n\n"
|
||||
# Say what the link's rules are. The reset mail always has; this one left the reader to
|
||||
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
||||
f"The link works once and expires in {hours} hours. If it does, you can ask for a fresh one "
|
||||
"from the sign-in page.\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
|
||||
@@ -167,6 +167,14 @@ class Invite(Base, CanonicalEmailMixin):
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
# When the admin last SENT this person an invitation from the whitelist row. Adding an
|
||||
# address stays silent (it opens a door, it does not knock), so telling them is a separate
|
||||
# act — and this is what stops it happening twice to a real person.
|
||||
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
|
||||
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
|
||||
# act, and this is what stops it happening twice to a real person.
|
||||
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class DemoWhitelist(Base, TimestampMixin):
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.auth import (
|
||||
admin_user,
|
||||
can_activate,
|
||||
count_admins,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
is_allowed,
|
||||
operator_contact,
|
||||
@@ -30,7 +31,7 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes.auth import send_verification_link
|
||||
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
@@ -71,6 +72,10 @@ def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
||||
"requested_at": iso(inv.requested_at),
|
||||
"decided_at": iso(inv.decided_at),
|
||||
"decided_by": inv.decided_by,
|
||||
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
|
||||
# without this the admin has no way to answer "did I already write to them?" — and the only
|
||||
# failure mode is writing to a real person twice.
|
||||
"invited_at": iso(inv.invited_at),
|
||||
# Drives the delete affordance: an entry backed by an account is not removable here.
|
||||
"has_account": has_account,
|
||||
# …and whether removing it would actually take access away, or leave the env allow-list
|
||||
@@ -96,6 +101,30 @@ def list_invites(
|
||||
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
|
||||
|
||||
|
||||
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
|
||||
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
|
||||
there is an unconfirmed account behind it), whether an account exists at all, and how long the
|
||||
link lasts.
|
||||
|
||||
This is the C option's whole mechanic. In the common ordering — the address is approved and the
|
||||
person signs up either side of that — they would otherwise have to find and open TWO messages:
|
||||
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
|
||||
already going out makes it one mail and one click.
|
||||
|
||||
Resolved through `find_user_by_email`, so an account registered under another spelling of the
|
||||
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
|
||||
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
|
||||
be worse than one that simply pointed at the sign-up page.
|
||||
"""
|
||||
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return None, False, hours
|
||||
if user.email_verified or not email_mod.email_enabled(db):
|
||||
return None, True, hours
|
||||
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
|
||||
|
||||
|
||||
def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
"""Approving access activates a matching (e.g. pending password-registration) account so
|
||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
|
||||
@@ -159,7 +188,11 @@ def approve_invite(
|
||||
) -> dict:
|
||||
inv = _decide(db, invite_id, admin, approved=True)
|
||||
_activate_user_by_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email)
|
||||
# Computed here, not in the background task: issuing the token needs this request's session, and
|
||||
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
|
||||
# invitation below it is sent automatically — a reply is owed, not a nudge.
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email, link, has_account, hours)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@@ -211,6 +244,49 @@ def add_invite(
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/invite")
|
||||
def send_invitation(
|
||||
invite_id: int,
|
||||
background: BackgroundTasks,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Tell someone their address is whitelisted — only when the admin asks for it.
|
||||
|
||||
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
|
||||
already agreed it with the person, and an address can sit approved for weeks with nobody
|
||||
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
|
||||
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
|
||||
they have already written rather than writing twice.
|
||||
|
||||
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
|
||||
undecided, and inviting a denied address would contradict the decision."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if inv.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
|
||||
)
|
||||
if not email_mod.email_enabled(db):
|
||||
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
|
||||
# but a button the admin pressed must not answer with a success toast for a mail that was
|
||||
# never sent.
|
||||
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
inv.invited_at = datetime.now(timezone.utc)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.INVITE_SEND,
|
||||
target_type="invite",
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.commit()
|
||||
background.add_task(email_mod.send_access_invitation, inv.email, link, has_account, hours)
|
||||
return _serialize(inv, has_account=has_account)
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}")
|
||||
def delete_invite(
|
||||
invite_id: int,
|
||||
@@ -459,13 +535,16 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
|
||||
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
|
||||
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
|
||||
keeps getting bitten by."""
|
||||
rows = [(u, can_activate(db, u)) for u in _pending_access_rows(db)]
|
||||
# An account that is inactive, confirmed and merely waiting on a still-pending invite is ALREADY
|
||||
# an actionable row in the Access-requests queue above, with Approve on it. Listing it here too
|
||||
# would make the admin choose between two buttons for one person.
|
||||
return [
|
||||
_serialize_user(u) | {"can_activate": ca} for u, ca in rows if ca or not u.email_verified
|
||||
]
|
||||
out = []
|
||||
for u in _pending_access_rows(db):
|
||||
# The whole rule in one line: this address MAY sign in, and this account still can't. An
|
||||
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
|
||||
# to the queue above, which owns that decision. Without this, every fresh registration
|
||||
# appeared in both lists at once, which UAT caught on 2026-09-05.
|
||||
if not is_allowed(db, u.email):
|
||||
continue
|
||||
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
|
||||
@@ -43,6 +43,12 @@ from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import canonical_email, valid_email
|
||||
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
|
||||
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
|
||||
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
@@ -254,6 +260,12 @@ class EmailIn(BaseModel):
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
|
||||
# the choice exists anywhere the server can see: it lives in the browser until an account does,
|
||||
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
|
||||
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
|
||||
# the bilingual mail templates are E9.5 S7.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
@@ -718,11 +730,11 @@ def register(
|
||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||
background.add_task(_register_account, email, password)
|
||||
background.add_task(_register_account, email, password, body.lang)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _register_account(email: str, password: str) -> None:
|
||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||
"""Background worker for /register: create the pending account (+ verification email + admin
|
||||
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
||||
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
||||
@@ -734,7 +746,20 @@ def _register_account(email: str, password: str) -> None:
|
||||
select(User).where(User.email_canonical == canonical_email(email))
|
||||
).first()
|
||||
if existing is not None:
|
||||
return # already registered — nothing to do (the uniform "ok" was already returned)
|
||||
# Already registered. This used to return in silence, and that silence is the most
|
||||
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
|
||||
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
|
||||
# inbox", and waited for a message that did not exist.
|
||||
#
|
||||
# Re-sending the link instead leaks nothing new. The response and its timing are
|
||||
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
|
||||
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
|
||||
# for the same input — so it is not a new capability, just the end of a dead end. An
|
||||
# account that is already confirmed still gets nothing: there is nothing to confirm.
|
||||
user = existing[0]
|
||||
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
|
||||
send_verification_link(db, user)
|
||||
return
|
||||
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
||||
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
||||
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
||||
@@ -744,6 +769,8 @@ def _register_account(email: str, password: str) -> None:
|
||||
password_hash=hash_password(password),
|
||||
is_active=False,
|
||||
email_verified=not email_ok,
|
||||
# Only a supported code is stored, so a forged body can't put junk in preferences.
|
||||
preferences={"language": lang} if lang in ("en", "hu") else {},
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -775,6 +802,16 @@ def _register_account(email: str, password: str) -> None:
|
||||
email_mod.send_admin_new_request(admins, email)
|
||||
|
||||
|
||||
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
||||
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
|
||||
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
|
||||
|
||||
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
|
||||
kinds of link cannot drift apart in shape or in how they are consumed."""
|
||||
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
|
||||
return f"{_app_base()}/#verify={raw}"
|
||||
|
||||
|
||||
def send_verification_link(db: Session, user: User) -> None:
|
||||
"""Issue a fresh verification token for `user` and mail them the link.
|
||||
|
||||
@@ -786,8 +823,11 @@ def send_verification_link(db: Session, user: User) -> None:
|
||||
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
|
||||
by who triggered it is a bug waiting to be discovered by exactly one user.
|
||||
"""
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
email_mod.send_verify_email(user.email, f"{_app_base()}/#verify={raw}")
|
||||
email_mod.send_verify_email(
|
||||
user.email,
|
||||
issue_verification_link(db, user),
|
||||
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
|
||||
@@ -44,8 +44,9 @@ def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user
|
||||
|
||||
|
||||
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
|
||||
# They already have a pending invite with Approve on it in the queue above. Two buttons for one
|
||||
# person is worse than one.
|
||||
# They already have a pending invite with Approve on it in the queue above, and their address is
|
||||
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
|
||||
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="normal@example.com", status="pending"))
|
||||
db.commit()
|
||||
@@ -54,6 +55,17 @@ def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make
|
||||
assert "normal@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
|
||||
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
|
||||
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="fresh@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
|
||||
|
||||
assert "fresh@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
@@ -116,12 +128,15 @@ def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
|
||||
sent: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
auth_routes.email_mod, "send_verify_email", lambda to, link: sent.append((to, link))
|
||||
auth_routes.email_mod, "send_verify_email", lambda to, link, **kw: sent.append((to, link))
|
||||
)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
|
||||
# not the admin's to decide, but the mail may simply never have arrived (2026-09-03: the first
|
||||
# message to a fresh Gmail landed in Spam), so it is theirs to help with.
|
||||
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
|
||||
# with. The approved invite is what puts them on this screen at all: the list is "the address may
|
||||
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
|
||||
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
|
||||
|
||||
@@ -320,7 +320,9 @@ def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db,
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_verify_email", lambda to, link: sent.append(to))
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_verify_email", lambda to, link, **kw: sent.append(to)
|
||||
)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, to: True)
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
|
||||
|
||||
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
|
||||
another asking them to confirm their address. The C option carries the confirmation link inside the
|
||||
mail that is already going out, so the usual case is one mail and one click.
|
||||
|
||||
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
|
||||
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
|
||||
sent nothing at all, while answering with the same cheerful "check your inbox".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _capture(monkeypatch, name):
|
||||
"""Record the arguments a given email_mod.send_* was called with."""
|
||||
from app import email as email_mod
|
||||
|
||||
calls: list[tuple] = []
|
||||
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
|
||||
return calls
|
||||
|
||||
|
||||
def _smtp_on(monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
def _approve(client, invite_id):
|
||||
r = client.post(f"/api/admin/invites/{invite_id}/approve")
|
||||
assert r.status_code == 200
|
||||
return r
|
||||
|
||||
|
||||
# --- the approval mail branches ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(email, link, has_account, hours), _ = sent[0]
|
||||
assert email == "waiting@example.com" and has_account is True
|
||||
assert link and "#verify=" in link
|
||||
# The whole point: this is the ONLY message they now have to open.
|
||||
assert hours == 24 * 7
|
||||
|
||||
|
||||
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
_approve(as_user(admin), inv.id)
|
||||
token = sent[0][0][1].split("#verify=")[1]
|
||||
|
||||
r = as_user(admin).post("/auth/verify", json={"token": token})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).email_verified is True
|
||||
|
||||
|
||||
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
|
||||
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
|
||||
# before it is read, and the one-mail promise with it.
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
|
||||
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
|
||||
|
||||
|
||||
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="ready@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="ready@example.com", password=_PW, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, link, has_account, _), _ = sent[0]
|
||||
assert link is None and has_account is True # no link to offer, and none needed
|
||||
|
||||
|
||||
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, link, has_account, _), _ = sent[0]
|
||||
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
|
||||
# noticeable — the failure that went unnoticed on 2026-09-03.
|
||||
assert link is None and has_account is False
|
||||
|
||||
|
||||
# --- the deliberate invitation --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
approved = _capture(monkeypatch, "send_access_approved")
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
|
||||
|
||||
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
|
||||
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
|
||||
# asked to be nudged.
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is None
|
||||
assert approved == [] and invited == []
|
||||
|
||||
|
||||
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is not None
|
||||
assert invited[0][0][0] == "maybe@example.com"
|
||||
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
|
||||
# made — the wrong answer means mailing a real person twice.
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is not None
|
||||
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
|
||||
|
||||
|
||||
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="asking@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
|
||||
|
||||
|
||||
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
|
||||
|
||||
|
||||
# --- the silent register no-op --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/register", json={"email": "twice@example.com", "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert len(sent) == 1
|
||||
|
||||
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert len(sent) == 2
|
||||
assert sent[1][0][0] == "twice@example.com"
|
||||
|
||||
|
||||
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_verify_email")
|
||||
for _ in range(2):
|
||||
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
db.expire_all()
|
||||
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
make_user(email="done@example.com", password=_PW) # already verified
|
||||
|
||||
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
|
||||
|
||||
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
|
||||
# to someone who already did would be a lie.
|
||||
assert r.status_code == 200
|
||||
assert sent == []
|
||||
Reference in New Issue
Block a user